@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.
@@ -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
- // The run asks the hub for a new client with a GitHub OIDC token, so the stack needs no key and no
5
- // Samebase account. The token names the repository. The hub reads `/samebase-verification.json` at
6
- // the 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.
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. On a laptop,
13
- // `alchemy-convex hub-client --shared` reads that row with the Convex login of the team and asks the
14
- // hub for a development client with the token: see `registerSharedDevelopmentClient`.
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 request
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 { reviveState } from "alchemy/State";
31
- import * as Cause from "effect/Cause";
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
- /** The hub that a run asks, which is also its issuer. */
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
- export interface SharedHubClientAttributes {
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
- readonly status: "on" | "off" | "suspended";
77
+ status: Schema.Literals(["on", "off", "suspended"]),
72
78
  /** The issuer of the hub, which the deployment names to sign in. */
73
- readonly issuer: string;
74
- readonly clientId: string;
79
+ issuer: Schema.String,
80
+ clientId: Schema.String,
75
81
  /** Returned only at registration. */
76
- readonly clientSecret: Redacted.Redacted<string>;
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
- readonly registrationAccessToken: Redacted.Redacted<string>;
79
- readonly registrationClientUri: string;
80
- readonly deploymentType: "production" | "preview";
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
- readonly redirectUri: string;
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, such as `alchemy-convex hub-client --shared` on a laptop. Empty for a
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
- readonly initialAccessToken: Redacted.Redacted<string>;
93
+ initialAccessToken: Schema.Redacted(Schema.String),
89
94
  /** The numeric GitHub id of the repository, with the initial access token. Empty without it. */
90
- readonly repository: string;
91
- }
95
+ repository: Schema.String,
96
+ });
92
97
 
93
- export type SharedHubClient = Resource<
94
- "IdentityHub.SharedClient",
95
- SharedHubClientProps,
96
- SharedHubClientAttributes,
97
- never,
98
- Providers
99
- >;
100
- export const SharedHubClient = Resource<SharedHubClient>("IdentityHub.SharedClient");
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
- const requestNewClient = (props: SharedHubClientProps, requestUrl: string, requestToken: string) =>
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 token = yield* readOidcToken(requestUrl, requestToken);
220
- const { body, status } = yield* send("The identity hub", hubClientsUrl(), token, {
221
- method: "POST",
222
- body: JSON.stringify({
223
- app: props.app,
224
- redirectUri: props.redirectUri,
225
- softwareVersion: props.softwareVersion ?? "unknown",
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
- return yield* new SharedHubClientFailed({
230
- reason: `${refusalOf(status, body)} (HTTP ${status})`,
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 `saved`, or the off values. `saved` holds no off values. */
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: SharedHubClientAttributes = {
255
- status: "off",
256
- issuer: OFF_VALUES.issuer,
257
- clientId: OFF_VALUES.clientId,
258
- clientSecret: Redacted.make(OFF_VALUES.clientSecret),
259
- registrationAccessToken: Redacted.make(""),
260
- registrationClientUri: "",
261
- deploymentType: props.deploymentType,
262
- redirectUri: "",
263
- initialAccessToken: Redacted.make(""),
264
- repository: "",
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 requestNewClient(props, requestUrl, requestToken).pipe(
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 = (stored: SharedHubClientAttributes) => {
297
- const { initialAccessToken = Redacted.make(""), repository = "" } =
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 = output?.status === "off" ? undefined : output;
342
- const renewed = saved?.redirectUri === news.redirectUri ? yield* renew(saved) : undefined;
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 client cleans it up.
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
- );