@samebase/alchemy-convex 0.26.0 → 0.27.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 +89 -40
- package/lib/{SharedHubClient-D-FJJUCZ.js → HubClient-Bs5_j18U.js} +10 -403
- package/lib/HubClient-Bs5_j18U.js.map +1 -0
- package/lib/cli.js +17 -34
- package/lib/cli.js.map +1 -1
- package/lib/index.d.ts +55 -18
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +268 -1
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/SharedHubClient.ts +224 -379
- package/src/cli.ts +18 -62
- package/lib/SharedHubClient-D-FJJUCZ.js.map +0 -1
package/src/SharedHubClient.ts
CHANGED
|
@@ -1,44 +1,39 @@
|
|
|
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, on any host
|
|
2
|
+
// app that deploys in its own GitHub Actions run, on any host, and on a laptop as a stage of the same
|
|
3
|
+
// stack.
|
|
3
4
|
//
|
|
4
|
-
//
|
|
5
|
-
// Samebase account. The token names the repository. The hub reads
|
|
6
|
-
// the origin of the return address, which must name the same
|
|
7
|
-
// that address. The answer holds the client's own
|
|
8
|
-
// later deploy reads the client on the hub with that
|
|
9
|
-
// the state is the only record of the secret and the
|
|
5
|
+
// A run in GitHub Actions asks the hub for a new client with a GitHub OIDC token, so the stack needs
|
|
6
|
+
// no key and no Samebase account. The token names the repository. The hub reads
|
|
7
|
+
// `/samebase-verification.json` at the origin of the return address, which must name the same
|
|
8
|
+
// repository, and registers the client on that address. The answer holds the client's own
|
|
9
|
+
// registration access token (RFC 7591), and each later deploy reads the client on the hub with that
|
|
10
|
+
// token (RFC 7592). The hub keeps only hashes, so the state is the only record of the secret and the
|
|
11
|
+
// token.
|
|
10
12
|
//
|
|
11
13
|
// The answer to a production registration also holds the app's initial access token (RFC 7591
|
|
12
|
-
// section 1.2) and the repository id. The state of stage `prod` keeps them.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
14
|
+
// section 1.2) and the repository id. The state of stage `prod` keeps them. A laptop runs the stack
|
|
15
|
+
// as a stage `dev-<name>`: it reads them with `SharedHubClient.read`, and a client with
|
|
16
|
+
// `deploymentType: "development"` asks the hub with the token in place of the GitHub token (RFC 133).
|
|
15
17
|
//
|
|
16
18
|
// A push removes a variable that a deploy stops passing, so a failure here must not drop working
|
|
17
19
|
// variables or stop the deploy. A renewal that fails keeps the saved values. Only a client that
|
|
18
|
-
// cannot work asks for a new one: it is gone, or it has another return address. If that
|
|
19
|
-
// fails, the run gives the off values, so the app has guest sign-in and not a broken button.
|
|
20
|
+
// cannot work asks for a new one: it is gone, or it has another return address or type. If that
|
|
21
|
+
// request fails, the run gives the off values, so the app has guest sign-in and not a broken button.
|
|
20
22
|
// The off values are the `no-hub` variables of a checkout without a hub: Convex needs the variables,
|
|
21
23
|
// and the app hides the button for this client ID.
|
|
22
24
|
//
|
|
23
25
|
// No text in this file quotes an answer or a fetch error, so no log line can hold a secret.
|
|
24
|
-
import { execFileSync } from "node:child_process";
|
|
25
|
-
import { readFile } from "node:fs/promises";
|
|
26
|
-
import { dirname, join } from "node:path";
|
|
27
26
|
import { Resource } from "alchemy";
|
|
28
27
|
import { isResolved } from "alchemy/Diff";
|
|
28
|
+
import * as Output from "alchemy/Output";
|
|
29
29
|
import * as Provider from "alchemy/Provider";
|
|
30
|
-
import {
|
|
31
|
-
import
|
|
30
|
+
import { Stack } from "alchemy/Stack";
|
|
31
|
+
import { State } from "alchemy/State";
|
|
32
32
|
import * as Effect from "effect/Effect";
|
|
33
33
|
import * as Option from "effect/Option";
|
|
34
34
|
import * as Redacted from "effect/Redacted";
|
|
35
35
|
import * as Schema from "effect/Schema";
|
|
36
|
-
import { DeploymentRow } from "./Deployment.ts";
|
|
37
|
-
import { ManagementApi, type ManagementApiService } from "./ManagementApi.ts";
|
|
38
|
-
import { findProjectByName, getProjectById, prodDeploymentOf } from "./Project.ts";
|
|
39
36
|
import type { Providers } from "./Providers.ts";
|
|
40
|
-
import { withPushKey } from "./Push.ts";
|
|
41
|
-
import { makeFunctionApi, STORE_PROJECT_NAME } from "./State.ts";
|
|
42
37
|
|
|
43
38
|
/** A hung service must not hold a deploy: the time limit of one request. */
|
|
44
39
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
@@ -46,58 +41,108 @@ const REQUEST_TIMEOUT_MS = 30_000;
|
|
|
46
41
|
/** The variables of a deployment without a hub client, as the starter writes them. */
|
|
47
42
|
const OFF_VALUES = { issuer: "http://127.0.0.1:1", clientId: "no-hub", clientSecret: "no-hub" };
|
|
48
43
|
|
|
49
|
-
|
|
50
|
-
const hubUrl = () => process.env["HUB_URL"] ?? "https://auth.samebase.com";
|
|
51
|
-
|
|
52
|
-
/** Where a run asks for a new client. */
|
|
53
|
-
const hubClientsUrl = () => `${hubUrl()}/hub-clients`;
|
|
54
|
-
|
|
55
|
-
export interface SharedHubClientProps {
|
|
44
|
+
interface CommonProps {
|
|
56
45
|
/** The stable name of the app, set once in the stack. */
|
|
57
46
|
readonly app: string;
|
|
58
47
|
/**
|
|
59
48
|
* The return address of the hub's sign-ins, such as `${site.siteUrl}/auth/hub/callback`: an https
|
|
60
49
|
* URL whose origin serves `/samebase-verification.json` with the numeric GitHub id of this
|
|
61
|
-
* repository, `{ "repository": "123" }`. It can come from a resource that this run creates.
|
|
50
|
+
* repository, `{ "repository": "123" }`. It can come from a resource that this run creates. A
|
|
51
|
+
* development client can also have http on a loopback host, which needs no file.
|
|
62
52
|
*/
|
|
63
53
|
readonly redirectUri: string;
|
|
64
|
-
readonly deploymentType: "production" | "preview";
|
|
65
54
|
/** Recorded on the hub's client, such as the commit. The hub records "unknown" without it. */
|
|
66
55
|
readonly softwareVersion?: string;
|
|
67
56
|
}
|
|
68
57
|
|
|
69
|
-
|
|
58
|
+
/**
|
|
59
|
+
* A production or preview client asks with the GitHub OIDC token of the run. A development client
|
|
60
|
+
* asks with the app's initial access token, which `SharedHubClient.read` gives from stage `prod`.
|
|
61
|
+
*/
|
|
62
|
+
export type SharedHubClientProps =
|
|
63
|
+
| (CommonProps & {
|
|
64
|
+
readonly deploymentType: "production" | "preview";
|
|
65
|
+
readonly initialAccessToken?: never;
|
|
66
|
+
})
|
|
67
|
+
| (CommonProps & {
|
|
68
|
+
readonly deploymentType: "development";
|
|
69
|
+
/** Empty before the first production registration: the client gets the off values. */
|
|
70
|
+
readonly initialAccessToken: Redacted.Redacted<string>;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const DeploymentType = Schema.Literals(["production", "preview", "development"]);
|
|
74
|
+
|
|
75
|
+
const Attributes = Schema.Struct({
|
|
70
76
|
/** `off`: no client, and the values below are the off values. */
|
|
71
|
-
|
|
77
|
+
status: Schema.Literals(["on", "off", "suspended"]),
|
|
72
78
|
/** The issuer of the hub, which the deployment names to sign in. */
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
issuer: Schema.String,
|
|
80
|
+
clientId: Schema.String,
|
|
75
81
|
/** Returned only at registration. */
|
|
76
|
-
|
|
82
|
+
clientSecret: Schema.Redacted(Schema.String),
|
|
77
83
|
/** Returned only at registration. It reads and deletes this client on the hub, and nothing else. */
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
84
|
+
registrationAccessToken: Schema.Redacted(Schema.String),
|
|
85
|
+
registrationClientUri: Schema.String,
|
|
86
|
+
deploymentType: DeploymentType,
|
|
81
87
|
/** The return address that the client serves. Empty for the off values. */
|
|
82
|
-
|
|
88
|
+
redirectUri: Schema.String,
|
|
83
89
|
/**
|
|
84
90
|
* Returned only at a production registration (RFC 7591 section 1.2). It registers development
|
|
85
|
-
* clients of this app
|
|
86
|
-
* preview and for the off values.
|
|
91
|
+
* clients of this app. Empty for a preview, for a development client, and for the off values.
|
|
87
92
|
*/
|
|
88
|
-
|
|
93
|
+
initialAccessToken: Schema.Redacted(Schema.String),
|
|
89
94
|
/** The numeric GitHub id of the repository, with the initial access token. Empty without it. */
|
|
90
|
-
|
|
91
|
-
}
|
|
95
|
+
repository: Schema.String,
|
|
96
|
+
});
|
|
92
97
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
/**
|
|
99
|
+
* The attributes keep the real values of a suspended client, so that an unblock needs no new client.
|
|
100
|
+
* `SharedHubClient.variables` gives the values that the app gets.
|
|
101
|
+
*/
|
|
102
|
+
export interface SharedHubClientAttributes extends Schema.Schema.Type<typeof Attributes> {}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The attributes as the state saves them. A row of 0.25 and earlier has no token fields, and they
|
|
106
|
+
* stay empty until the next production registration.
|
|
107
|
+
*/
|
|
108
|
+
const SavedAttributes = Schema.Struct({
|
|
109
|
+
...Attributes.fields,
|
|
110
|
+
initialAccessToken: Schema.optionalKey(Schema.Redacted(Schema.String)),
|
|
111
|
+
repository: Schema.optionalKey(Schema.String),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const savedAttributes = (value: unknown) =>
|
|
115
|
+
Schema.decodeUnknownEffect(SavedAttributes)(value).pipe(
|
|
116
|
+
Effect.map(({ initialAccessToken = Redacted.make(""), repository = "", ...attributes }) => ({
|
|
117
|
+
...attributes,
|
|
118
|
+
initialAccessToken,
|
|
119
|
+
repository,
|
|
120
|
+
})),
|
|
121
|
+
// The text of a schema issue can quote a value of the row.
|
|
122
|
+
Effect.catchCause(() =>
|
|
123
|
+
Effect.die(
|
|
124
|
+
new SharedHubClientFailed({
|
|
125
|
+
reason: "the state holds attributes of Convex.SharedHubClient that it cannot read",
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
/** The attributes of no client. */
|
|
132
|
+
const offAttributes = (
|
|
133
|
+
deploymentType: SharedHubClientAttributes["deploymentType"],
|
|
134
|
+
): SharedHubClientAttributes => ({
|
|
135
|
+
status: "off",
|
|
136
|
+
issuer: OFF_VALUES.issuer,
|
|
137
|
+
clientId: OFF_VALUES.clientId,
|
|
138
|
+
clientSecret: Redacted.make(OFF_VALUES.clientSecret),
|
|
139
|
+
registrationAccessToken: Redacted.make(""),
|
|
140
|
+
registrationClientUri: "",
|
|
141
|
+
deploymentType,
|
|
142
|
+
redirectUri: "",
|
|
143
|
+
initialAccessToken: Redacted.make(""),
|
|
144
|
+
repository: "",
|
|
145
|
+
});
|
|
101
146
|
|
|
102
147
|
/** One request gave no usable answer. The reason holds no secret. */
|
|
103
148
|
class SharedHubClientFailed extends Schema.TaggedError<SharedHubClientFailed>()(
|
|
@@ -214,21 +259,28 @@ const readOidcToken = (requestUrl: string, requestToken: string) =>
|
|
|
214
259
|
return (yield* decode(OidcToken, "GitHub")(body)).value;
|
|
215
260
|
});
|
|
216
261
|
|
|
217
|
-
|
|
262
|
+
/** `POST /hub-clients` with `bearer`: the GitHub OIDC token, or the app's initial access token. */
|
|
263
|
+
const requestNewClient = (props: SharedHubClientProps, bearer: string) =>
|
|
218
264
|
Effect.gen(function* () {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
265
|
+
const { body, status } = yield* send(
|
|
266
|
+
"The identity hub",
|
|
267
|
+
`${process.env["HUB_URL"] ?? "https://auth.samebase.com"}/hub-clients`,
|
|
268
|
+
bearer,
|
|
269
|
+
{
|
|
270
|
+
method: "POST",
|
|
271
|
+
body: JSON.stringify({
|
|
272
|
+
app: props.app,
|
|
273
|
+
redirectUri: props.redirectUri,
|
|
274
|
+
softwareVersion: props.softwareVersion ?? "unknown",
|
|
275
|
+
}),
|
|
276
|
+
},
|
|
277
|
+
);
|
|
228
278
|
if (status !== 201) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
279
|
+
const reason =
|
|
280
|
+
status === 401 && props.deploymentType === "development"
|
|
281
|
+
? "the identity hub refused the initial access token"
|
|
282
|
+
: refusalOf(status, body);
|
|
283
|
+
return yield* new SharedHubClientFailed({ reason: `${reason} (HTTP ${status})` });
|
|
232
284
|
}
|
|
233
285
|
const client = yield* decode(NewClient, "The identity hub")(body);
|
|
234
286
|
yield* Effect.logInfo(
|
|
@@ -248,21 +300,29 @@ const requestNewClient = (props: SharedHubClientProps, requestUrl: string, reque
|
|
|
248
300
|
};
|
|
249
301
|
});
|
|
250
302
|
|
|
251
|
-
/** A new client, or
|
|
303
|
+
/** A new client, or the off values. */
|
|
252
304
|
const newClient = (props: SharedHubClientProps) => {
|
|
253
305
|
const label = `Convex.SharedHubClient: ${props.app} on ${props.redirectUri} deploys without the hub`;
|
|
254
|
-
const off
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
306
|
+
const off = offAttributes(props.deploymentType);
|
|
307
|
+
const request = (bearer: Effect.Effect<string, SharedHubClientFailed>) =>
|
|
308
|
+
bearer.pipe(
|
|
309
|
+
Effect.flatMap((token) => requestNewClient(props, token)),
|
|
310
|
+
Effect.catchTag("SharedHubClientFailed", (error) =>
|
|
311
|
+
Effect.logWarning(`${label}: ${error.reason}. The next deploy asks again.`).pipe(
|
|
312
|
+
Effect.as(off),
|
|
313
|
+
),
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
if (props.deploymentType === "development") {
|
|
318
|
+
const token = Redacted.value(props.initialAccessToken);
|
|
319
|
+
return token === ""
|
|
320
|
+
? Effect.logInfo(
|
|
321
|
+
"Convex.SharedHubClient: deploy to main once for Google sign-in on this laptop.",
|
|
322
|
+
).pipe(Effect.as(off))
|
|
323
|
+
: request(Effect.succeed(token));
|
|
324
|
+
}
|
|
325
|
+
|
|
266
326
|
const requestUrl = process.env["ACTIONS_ID_TOKEN_REQUEST_URL"];
|
|
267
327
|
const requestToken = process.env["ACTIONS_ID_TOKEN_REQUEST_TOKEN"];
|
|
268
328
|
// A laptop, or another CI. In a workflow, `id-token: write` gives both, so a workflow without them
|
|
@@ -274,30 +334,12 @@ const newClient = (props: SharedHubClientProps) => {
|
|
|
274
334
|
: Effect.logInfo(`${label}: this run has no GitHub OIDC token.`)
|
|
275
335
|
).pipe(Effect.as(off));
|
|
276
336
|
}
|
|
277
|
-
return
|
|
278
|
-
Effect.catchTag("SharedHubClientFailed", (error) =>
|
|
279
|
-
Effect.logWarning(`${label}: ${error.reason}. The next deploy asks again.`).pipe(
|
|
280
|
-
Effect.as(off),
|
|
281
|
-
),
|
|
282
|
-
),
|
|
283
|
-
);
|
|
337
|
+
return request(readOidcToken(requestUrl, requestToken));
|
|
284
338
|
};
|
|
285
339
|
|
|
286
|
-
/**
|
|
287
|
-
* The token fields of saved attributes. A row of 0.25 and earlier has neither, and they stay empty
|
|
288
|
-
* until the next production registration.
|
|
289
|
-
*/
|
|
290
|
-
const StoredToken = Schema.Struct({
|
|
291
|
-
initialAccessToken: Schema.optionalKey(Schema.Redacted(Schema.String)),
|
|
292
|
-
repository: Schema.optionalKey(Schema.String),
|
|
293
|
-
});
|
|
294
|
-
|
|
295
340
|
/** The client after a read on the hub, or undefined when the hub says that the client is gone. */
|
|
296
|
-
const renew = (
|
|
297
|
-
|
|
298
|
-
Schema.decodeUnknownSync(StoredToken)(stored);
|
|
299
|
-
const saved = { ...stored, initialAccessToken, repository };
|
|
300
|
-
return Effect.gen(function* () {
|
|
341
|
+
const renew = (saved: SharedHubClientAttributes) =>
|
|
342
|
+
Effect.gen(function* () {
|
|
301
343
|
const { status } = yield* send(
|
|
302
344
|
"The identity hub",
|
|
303
345
|
saved.registrationClientUri,
|
|
@@ -326,7 +368,77 @@ const renew = (stored: SharedHubClientAttributes) => {
|
|
|
326
368
|
).pipe(Effect.as(saved)),
|
|
327
369
|
),
|
|
328
370
|
);
|
|
329
|
-
|
|
371
|
+
|
|
372
|
+
/** A saved row of this resource, whose `attr` `savedAttributes` reads. */
|
|
373
|
+
const SavedRow = Schema.Struct({
|
|
374
|
+
resourceType: Schema.Literal("IdentityHub.SharedClient"),
|
|
375
|
+
attr: Schema.Unknown,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The attributes of the client `id` in another stage of this stack, such as
|
|
380
|
+
* `read("HubClient", { stage: "prod" })` in the stage of a laptop, as Outputs: the
|
|
381
|
+
* `initialAccessToken` and the `repository` of the production client. Without that row, the off
|
|
382
|
+
* attributes, with `deploymentType: "production"`.
|
|
383
|
+
*
|
|
384
|
+
* `SharedHubClient.ref` is not enough: Alchemy fails the plan with `InvalidReferenceError` when the
|
|
385
|
+
* row is missing, and a laptop before the first deploy to main has none. This read runs when the
|
|
386
|
+
* plan resolves it, with the State service of the running stack and its `get`, so it never creates
|
|
387
|
+
* or upgrades a store. The store revives each secret as `Redacted`, as it does for every read.
|
|
388
|
+
*/
|
|
389
|
+
const read = (id: string, options: { readonly stage: string }) =>
|
|
390
|
+
Output.fromEffect(
|
|
391
|
+
Effect.gen(function* () {
|
|
392
|
+
const stack = yield* Stack;
|
|
393
|
+
const state = yield* yield* State;
|
|
394
|
+
const row = yield* state
|
|
395
|
+
.get({ stack: stack.name, stage: options.stage, fqn: id })
|
|
396
|
+
.pipe(Effect.orDie);
|
|
397
|
+
if (row === undefined) return offAttributes("production");
|
|
398
|
+
const { attr } = yield* Schema.decodeUnknownEffect(SavedRow)(row).pipe(
|
|
399
|
+
Effect.catchCause(() =>
|
|
400
|
+
Effect.die(
|
|
401
|
+
new SharedHubClientFailed({
|
|
402
|
+
reason: `the row ${id} of stage ${options.stage} is not a Convex.SharedHubClient`,
|
|
403
|
+
}),
|
|
404
|
+
),
|
|
405
|
+
),
|
|
406
|
+
);
|
|
407
|
+
return yield* savedAttributes(attr);
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The three values that the app's deployment gets, such as `HUB_ISSUER`, `HUB_CLIENT_ID`, and
|
|
413
|
+
* `HUB_CLIENT_SECRET`, as Outputs: the client's values when `status` is `on`, else the off values,
|
|
414
|
+
* so that a suspended client gives guest sign-in and keeps its values in state.
|
|
415
|
+
*/
|
|
416
|
+
const variables = (client: SharedHubClient) =>
|
|
417
|
+
Output.map(Output.of(client), (attributes) =>
|
|
418
|
+
attributes.status === "on"
|
|
419
|
+
? {
|
|
420
|
+
issuer: attributes.issuer,
|
|
421
|
+
clientId: attributes.clientId,
|
|
422
|
+
clientSecret: attributes.clientSecret,
|
|
423
|
+
}
|
|
424
|
+
: {
|
|
425
|
+
issuer: OFF_VALUES.issuer,
|
|
426
|
+
clientId: OFF_VALUES.clientId,
|
|
427
|
+
clientSecret: Redacted.make(OFF_VALUES.clientSecret),
|
|
428
|
+
},
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
export type SharedHubClient = Resource<
|
|
432
|
+
"IdentityHub.SharedClient",
|
|
433
|
+
SharedHubClientProps,
|
|
434
|
+
SharedHubClientAttributes,
|
|
435
|
+
never,
|
|
436
|
+
Providers
|
|
437
|
+
>;
|
|
438
|
+
export const SharedHubClient = Resource<SharedHubClient>("IdentityHub.SharedClient")({
|
|
439
|
+
read,
|
|
440
|
+
variables,
|
|
441
|
+
});
|
|
330
442
|
|
|
331
443
|
export const SharedHubClientProvider = () =>
|
|
332
444
|
Provider.succeed(SharedHubClient, {
|
|
@@ -337,14 +449,21 @@ export const SharedHubClientProvider = () =>
|
|
|
337
449
|
|
|
338
450
|
read: ({ output }) => Effect.succeed(output),
|
|
339
451
|
|
|
452
|
+
// Only a client of the same return address and type is renewed.
|
|
340
453
|
reconcile: Effect.fn(function* ({ news, output }) {
|
|
341
|
-
const saved =
|
|
342
|
-
|
|
454
|
+
const saved =
|
|
455
|
+
output === undefined || output.status === "off"
|
|
456
|
+
? undefined
|
|
457
|
+
: yield* savedAttributes(output);
|
|
458
|
+
const renewed =
|
|
459
|
+
saved?.redirectUri === news.redirectUri && saved.deploymentType === news.deploymentType
|
|
460
|
+
? yield* renew(saved)
|
|
461
|
+
: undefined;
|
|
343
462
|
return renewed ?? (yield* newClient(news));
|
|
344
463
|
}),
|
|
345
464
|
|
|
346
|
-
// A failed delete does not stop a destroy: the removal date of a preview
|
|
347
|
-
// A destroy can leave a production deployment running, so its client stays.
|
|
465
|
+
// A failed delete does not stop a destroy: the removal date of a preview or development client
|
|
466
|
+
// cleans it up. A destroy can leave a production deployment running, so its client stays.
|
|
348
467
|
delete: ({ output }) =>
|
|
349
468
|
output.deploymentType === "production" || output.status === "off"
|
|
350
469
|
? Effect.void
|
|
@@ -369,277 +488,3 @@ export const SharedHubClientProvider = () =>
|
|
|
369
488
|
),
|
|
370
489
|
),
|
|
371
490
|
});
|
|
372
|
-
|
|
373
|
-
export interface SharedDevelopmentClientOptions {
|
|
374
|
-
/** The stack of the app, the repository `owner/name`. Its stage `prod` holds the token. */
|
|
375
|
-
readonly stack: string;
|
|
376
|
-
readonly app: string;
|
|
377
|
-
/** The path of the hub's callback after the site URL of the deployment. */
|
|
378
|
-
readonly callbackPath: string;
|
|
379
|
-
/** Prefix of the five deployment variables. */
|
|
380
|
-
readonly envPrefix?: string;
|
|
381
|
-
/** The Convex team, a slug or an id, whose state store holds the stack. */
|
|
382
|
-
readonly team?: string;
|
|
383
|
-
/** The directory of the app. */
|
|
384
|
-
readonly cwd: string;
|
|
385
|
-
/** Runs the Convex CLI on the deployment of `cwd`, and answers its output. */
|
|
386
|
-
readonly convex: (args: readonly string[], input?: string) => string;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/** The logical id of the client in the stack of the starter. */
|
|
390
|
-
const CLIENT_FQN = "HubClient";
|
|
391
|
-
|
|
392
|
-
/** The one action that `state:get` needs. */
|
|
393
|
-
const READ_KEY_ACTIONS = ["deployment:functions:runInternalQueries"] as const;
|
|
394
|
-
|
|
395
|
-
/** `CONVEX_DEPLOYMENT` of `.env.local` for a cloud development deployment, such as `dev:happy-otter-123`. */
|
|
396
|
-
const CLOUD_DEPLOYMENT = /^CONVEX_DEPLOYMENT=(?:dev:)?([a-z0-9]+(?:-[a-z0-9]+)*)[ \t]*(?:#.*)?$/m;
|
|
397
|
-
|
|
398
|
-
/** The client row of stage `prod` with a token. A row without one fails to decode. */
|
|
399
|
-
const SavedToken = Schema.Struct({
|
|
400
|
-
resourceType: Schema.Literal("IdentityHub.SharedClient"),
|
|
401
|
-
attr: Schema.Struct({
|
|
402
|
-
initialAccessToken: Schema.Redacted(Schema.NonEmptyString),
|
|
403
|
-
repository: Schema.NonEmptyString,
|
|
404
|
-
}),
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
/** The part of a client's own record (RFC 7592) that a renewal compares. */
|
|
408
|
-
const ClientRecord = Schema.Struct({ redirect_uris: Schema.Array(Schema.String) });
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* The directory whose `.env.local` selects the cloud development deployment of `cwd`. A linked git
|
|
412
|
-
* worktree runs a local backend, so the main checkout, the parent of the common git directory,
|
|
413
|
-
* selects it.
|
|
414
|
-
*/
|
|
415
|
-
const selectingDirectory = (cwd: string) =>
|
|
416
|
-
Effect.try({
|
|
417
|
-
try: () =>
|
|
418
|
-
// The prefix is the path of `cwd` in the checkout, and an empty line at its root.
|
|
419
|
-
execFileSync(
|
|
420
|
-
"git",
|
|
421
|
-
["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir", "--show-prefix"],
|
|
422
|
-
{ cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
423
|
-
).split(/\r?\n/),
|
|
424
|
-
catch: () => undefined,
|
|
425
|
-
}).pipe(
|
|
426
|
-
Effect.map(([gitDir, commonDir, prefix = ""]) =>
|
|
427
|
-
gitDir === undefined || commonDir === undefined || gitDir === commonDir
|
|
428
|
-
? cwd
|
|
429
|
-
: join(dirname(commonDir), prefix),
|
|
430
|
-
),
|
|
431
|
-
Effect.orElseSucceed(() => cwd),
|
|
432
|
-
);
|
|
433
|
-
|
|
434
|
-
/** The team of the cloud development deployment that `.env.local` selects, or undefined. */
|
|
435
|
-
const teamOfSelectedDeployment = (api: ManagementApiService, cwd: string) =>
|
|
436
|
-
Effect.gen(function* () {
|
|
437
|
-
const directory = yield* selectingDirectory(cwd);
|
|
438
|
-
const text = yield* Effect.tryPromise(() =>
|
|
439
|
-
readFile(join(directory, ".env.local"), "utf8"),
|
|
440
|
-
).pipe(Effect.orElseSucceed(() => ""));
|
|
441
|
-
const name = CLOUD_DEPLOYMENT.exec(text)?.[1];
|
|
442
|
-
if (name === undefined) return undefined;
|
|
443
|
-
const { projectId } = yield* api
|
|
444
|
-
.request("get deployment", (client) =>
|
|
445
|
-
client.GET("/deployments/{deployment_name}", {
|
|
446
|
-
params: { path: { deployment_name: name } },
|
|
447
|
-
}),
|
|
448
|
-
)
|
|
449
|
-
.pipe(Effect.flatMap(Schema.decodeUnknownEffect(DeploymentRow)));
|
|
450
|
-
return (yield* getProjectById(api, projectId))?.teamId;
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* The initial access token and the repository id in the client row of stage `prod` of `stack`, or
|
|
455
|
-
* undefined. It reads the row with the store's own `state:get` and a key that runs internal queries
|
|
456
|
-
* only. It never builds `Convex.state()`, which can create or upgrade the store.
|
|
457
|
-
*/
|
|
458
|
-
const readSavedToken = (api: ManagementApiService, team: string | number, stack: string) =>
|
|
459
|
-
Effect.gen(function* () {
|
|
460
|
-
const project = yield* findProjectByName(
|
|
461
|
-
api,
|
|
462
|
-
yield* api.resolveTeamId(team),
|
|
463
|
-
STORE_PROJECT_NAME,
|
|
464
|
-
);
|
|
465
|
-
const deployment = project === undefined ? undefined : yield* prodDeploymentOf(api, project.id);
|
|
466
|
-
if (deployment === undefined) return undefined;
|
|
467
|
-
const reply = yield* withPushKey(api, deployment.name, READ_KEY_ACTIONS, (key) =>
|
|
468
|
-
makeFunctionApi(deployment.deploymentUrl, key).call("state:get", {
|
|
469
|
-
stack,
|
|
470
|
-
stage: "prod",
|
|
471
|
-
fqn: CLIENT_FQN,
|
|
472
|
-
}),
|
|
473
|
-
);
|
|
474
|
-
if (reply.status === "error") {
|
|
475
|
-
return yield* new SharedHubClientFailed({ reason: "the state store refused the read" });
|
|
476
|
-
}
|
|
477
|
-
const text = yield* decode(Schema.NullOr(Schema.String), "The state store")(reply.value);
|
|
478
|
-
if (text === null) return undefined;
|
|
479
|
-
const row = yield* Effect.try({
|
|
480
|
-
try: (): unknown => JSON.parse(text, reviveState),
|
|
481
|
-
catch: () => new SharedHubClientFailed({ reason: "the state store gave a bad row" }),
|
|
482
|
-
});
|
|
483
|
-
return Option.getOrUndefined(Schema.decodeUnknownOption(SavedToken)(row))?.attr;
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
/** Replaces any failure or defect of a step with a fixed text, which quotes nothing. */
|
|
487
|
-
const failsWith =
|
|
488
|
-
(reason: string) =>
|
|
489
|
-
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
490
|
-
effect.pipe(Effect.catchCause(() => Effect.fail(new SharedHubClientFailed({ reason }))));
|
|
491
|
-
|
|
492
|
-
/** What the hub says about the client that the variables name. */
|
|
493
|
-
const readClient = (uri: string, token: string, callbackUrl: string) =>
|
|
494
|
-
Effect.gen(function* () {
|
|
495
|
-
const { body, status } = yield* send("The identity hub", uri, token, { method: "GET" });
|
|
496
|
-
switch (status) {
|
|
497
|
-
case 200: {
|
|
498
|
-
const record = yield* decode(ClientRecord, "The identity hub")(body);
|
|
499
|
-
const [only, ...more] = record.redirect_uris;
|
|
500
|
-
return only === callbackUrl && more.length === 0 ? "renewed" : "moved";
|
|
501
|
-
}
|
|
502
|
-
case 401:
|
|
503
|
-
return "gone";
|
|
504
|
-
case 403:
|
|
505
|
-
return "stopped";
|
|
506
|
-
default:
|
|
507
|
-
return yield* new SharedHubClientFailed({
|
|
508
|
-
reason: `the identity hub answered HTTP ${status}`,
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* `alchemy-convex hub-client --shared`: a development client of an app on the central identity hub,
|
|
515
|
-
* for `pnpm run dev` on a laptop, with the initial access token that the stack saved at the
|
|
516
|
-
* production registration (RFC 133). Run it after the first push of each dev start. Answers the one
|
|
517
|
-
* line to print. It fails only for a defect: a refusal, a failed request, or no token keeps the
|
|
518
|
-
* variables and answers why.
|
|
519
|
-
*
|
|
520
|
-
* 1. The team: `team`, else `CONVEX_TEAM_ID`, else the team of the selected cloud development
|
|
521
|
-
* deployment.
|
|
522
|
-
* 2. The five variables name a client of this hub (`<prefix>_ISSUER` is `HUB_URL` or
|
|
523
|
-
* `https://auth.samebase.com`) with a real client ID and secret: the hub renews it on a read with
|
|
524
|
-
* its own token (RFC 7592) when its `redirect_uris` are exactly the current callback. A client on
|
|
525
|
-
* another address, or one that the hub no longer has, gets a new client. A client that the hub
|
|
526
|
-
* stopped gets the off values.
|
|
527
|
-
* 3. The token from the client row of stage `prod`, the repository id on an https site, and a new
|
|
528
|
-
* client, in one `env set --force`.
|
|
529
|
-
*
|
|
530
|
-
* The secret and both tokens reach the Convex CLI on standard input, never in its arguments, and no
|
|
531
|
-
* line holds them.
|
|
532
|
-
*/
|
|
533
|
-
export const registerSharedDevelopmentClient = (options: SharedDevelopmentClientOptions) =>
|
|
534
|
-
Effect.gen(function* () {
|
|
535
|
-
const api = yield* ManagementApi;
|
|
536
|
-
/** One call of the Convex CLI. Its error can quote the deployment, so it gives a fixed text. */
|
|
537
|
-
const convex = (args: readonly string[], input?: string) =>
|
|
538
|
-
Effect.try({
|
|
539
|
-
try: () => options.convex(args, input),
|
|
540
|
-
catch: () =>
|
|
541
|
-
new SharedHubClientFailed({
|
|
542
|
-
reason: `\`convex ${args.slice(0, 2).join(" ")}\` failed`,
|
|
543
|
-
}),
|
|
544
|
-
});
|
|
545
|
-
const named = options.team ?? process.env["CONVEX_TEAM_ID"];
|
|
546
|
-
const team =
|
|
547
|
-
named !== undefined && named !== ""
|
|
548
|
-
? named
|
|
549
|
-
: yield* teamOfSelectedDeployment(api, options.cwd).pipe(
|
|
550
|
-
failsWith("the Convex API could not read the selected development deployment"),
|
|
551
|
-
);
|
|
552
|
-
if (team === undefined) {
|
|
553
|
-
return "No Convex team for Google sign-in on this laptop: set CONVEX_TEAM_ID, or select a cloud development deployment.";
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
const prefix = options.envPrefix ?? "HUB";
|
|
557
|
-
const siteUrl = yield* decode(
|
|
558
|
-
Schema.fromJsonString(Schema.String),
|
|
559
|
-
"The deployment",
|
|
560
|
-
)(yield* convex(["run", "--inline-query", "process.env.CONVEX_SITE_URL"]));
|
|
561
|
-
const callbackUrl = `${siteUrl.replace(/\/$/, "")}${options.callbackPath}`;
|
|
562
|
-
|
|
563
|
-
const names = (yield* convex(["env", "list", "--names-only"])).split(/\r?\n/);
|
|
564
|
-
const variable = (name: string) => `${prefix}_${name}`;
|
|
565
|
-
const tokenVariable = variable("REGISTRATION_TOKEN");
|
|
566
|
-
const complete = [
|
|
567
|
-
"ISSUER",
|
|
568
|
-
"CLIENT_ID",
|
|
569
|
-
"CLIENT_SECRET",
|
|
570
|
-
"REGISTRATION_URI",
|
|
571
|
-
"REGISTRATION_TOKEN",
|
|
572
|
-
].every((name) => names.includes(variable(name)));
|
|
573
|
-
// Only a client of the hub that this run asks, with real values, is renewed: the off values,
|
|
574
|
-
// and a client of another hub, get a new client.
|
|
575
|
-
const renewable =
|
|
576
|
-
complete &&
|
|
577
|
-
(yield* convex(["env", "get", variable("ISSUER")])) === hubUrl() &&
|
|
578
|
-
(yield* convex(["env", "get", variable("CLIENT_ID")])) !== OFF_VALUES.clientId &&
|
|
579
|
-
![OFF_VALUES.clientSecret, ""].includes(
|
|
580
|
-
yield* convex(["env", "get", variable("CLIENT_SECRET")]),
|
|
581
|
-
);
|
|
582
|
-
if (renewable) {
|
|
583
|
-
const client = yield* readClient(
|
|
584
|
-
yield* convex(["env", "get", variable("REGISTRATION_URI")]),
|
|
585
|
-
yield* convex(["env", "get", tokenVariable]),
|
|
586
|
-
callbackUrl,
|
|
587
|
-
);
|
|
588
|
-
if (client === "renewed") return "Hub client renewed.";
|
|
589
|
-
if (client === "stopped") {
|
|
590
|
-
yield* convex(
|
|
591
|
-
["env", "set", "--force"],
|
|
592
|
-
`${prefix}_ISSUER='${OFF_VALUES.issuer}'\n${prefix}_CLIENT_ID='${OFF_VALUES.clientId}'\n${prefix}_CLIENT_SECRET='${OFF_VALUES.clientSecret}'\n`,
|
|
593
|
-
);
|
|
594
|
-
yield* convex(["env", "remove", variable("REGISTRATION_URI")]);
|
|
595
|
-
yield* convex(["env", "remove", tokenVariable]);
|
|
596
|
-
return "The identity hub stopped the hub client of this laptop (HTTP 403), so the laptop has guest sign-in.";
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
const saved = yield* readSavedToken(api, team, options.stack).pipe(
|
|
601
|
-
failsWith("the state store of the team could not be read"),
|
|
602
|
-
);
|
|
603
|
-
if (saved === undefined) return "Deploy to main once for Google sign-in on this laptop.";
|
|
604
|
-
if (new URL(siteUrl).protocol === "https:") {
|
|
605
|
-
// The hub reads /samebase-verification.json of an https site, which serves this variable.
|
|
606
|
-
yield* convex(["env", "set", "SAMEBASE_REPOSITORY_ID", saved.repository]);
|
|
607
|
-
}
|
|
608
|
-
const { body, status } = yield* send(
|
|
609
|
-
"The identity hub",
|
|
610
|
-
hubClientsUrl(),
|
|
611
|
-
Redacted.value(saved.initialAccessToken),
|
|
612
|
-
{
|
|
613
|
-
method: "POST",
|
|
614
|
-
body: JSON.stringify({
|
|
615
|
-
app: options.app,
|
|
616
|
-
redirectUri: callbackUrl,
|
|
617
|
-
softwareVersion: new URL(siteUrl).host,
|
|
618
|
-
}),
|
|
619
|
-
},
|
|
620
|
-
);
|
|
621
|
-
if (status !== 201) {
|
|
622
|
-
const reason =
|
|
623
|
-
status === 401
|
|
624
|
-
? "the identity hub refused the initial access token of the app"
|
|
625
|
-
: refusalOf(status, body);
|
|
626
|
-
return `The hub client keeps its values: ${reason} (HTTP ${status}).`;
|
|
627
|
-
}
|
|
628
|
-
const client = yield* decode(NewClient, "The identity hub")(body);
|
|
629
|
-
// One request, so that an interrupted run cannot leave half a client. `--force` replaces the
|
|
630
|
-
// values of an earlier one.
|
|
631
|
-
yield* convex(
|
|
632
|
-
["env", "set", "--force"],
|
|
633
|
-
`${prefix}_ISSUER='${client.issuer}'\n${prefix}_CLIENT_ID='${client.client_id}'\n${prefix}_CLIENT_SECRET='${client.client_secret}'\n${prefix}_REGISTRATION_URI='${client.registration_client_uri}'\n${tokenVariable}='${client.registration_access_token}'\n`,
|
|
634
|
-
);
|
|
635
|
-
return `Hub client registered for ${callbackUrl}.`;
|
|
636
|
-
}).pipe(
|
|
637
|
-
// A dev start goes on after any failure. Each step gives a fixed text, and the text of any
|
|
638
|
-
// other failure is never shown, because it can quote an answer or a secret.
|
|
639
|
-
Effect.catchCause((cause) => {
|
|
640
|
-
const error = Cause.squash(cause);
|
|
641
|
-
const reason =
|
|
642
|
-
error instanceof SharedHubClientFailed ? error.reason : "an unexpected failure";
|
|
643
|
-
return Effect.succeed(`The hub client keeps its values: ${reason}.`);
|
|
644
|
-
}),
|
|
645
|
-
);
|