@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 CHANGED
@@ -313,38 +313,52 @@ call.
313
313
  - A destroy deletes a preview or development client. It does nothing to a production client,
314
314
  because the production deployment can stay.
315
315
 
316
- - `Convex.SharedHubClient`: `{ app, redirectUri, deploymentType, softwareVersion? }` gets a client
317
- of the central identity hub, for an app that deploys in its own GitHub Actions run, on any host.
318
- The workflow needs `permissions: id-token: write`, and no key and no Samebase account. `app` is at
319
- most 64 characters. The origin of `redirectUri` must serve `/samebase-verification.json` with the
320
- numeric GitHub id of the repository, `{ "repository": "123" }`, which
321
- `gh api repos/<owner>/<name> --jq .id` prints. The hub reads it before it registers the client, so
322
- the site must be live first. Outputs `status`, `issuer`, `clientId`, `clientSecret`,
323
- `registrationAccessToken`, `registrationClientUri`, `deploymentType`, `redirectUri`,
324
- `initialAccessToken`, and `repository`.
316
+ - `Convex.SharedHubClient`:
317
+ `{ app, redirectUri, deploymentType, initialAccessToken?, softwareVersion? }` gets a client of the
318
+ central identity hub, for an app that deploys in its own GitHub Actions run, on any host, and for
319
+ a laptop as a stage of the same stack. `deploymentType` is `production`, `preview`, or
320
+ `development`. A production or preview client needs `permissions: id-token: write` in the
321
+ workflow, and no key and no Samebase account. `app` is at most 64 characters. The origin of an
322
+ https `redirectUri` must serve `/samebase-verification.json` with the numeric GitHub id of the
323
+ repository, `{ "repository": "123" }`, which `gh api repos/<owner>/<name> --jq .id` prints. The
324
+ hub reads it before it registers the client, so the site must be live first. Outputs `status`,
325
+ `issuer`, `clientId`, `clientSecret`, `registrationAccessToken`, `registrationClientUri`,
326
+ `deploymentType`, `redirectUri`, `initialAccessToken`, and `repository`.
325
327
  - A production registration also gets the app's initial access token (RFC 7591) and the numeric
326
- repository id, which the state keeps. The token registers development clients of the app on a
327
- laptop: see `hub-client --shared` below. A renewal keeps both. They are empty for a preview and
328
- for the off values.
328
+ repository id, which the state keeps. A renewal keeps both. They are empty for a preview, for a
329
+ development client, and for the off values.
330
+ - A development client needs `initialAccessToken`, and only a development client takes it. The
331
+ client asks the hub with this token in place of the GitHub OIDC token, so it works on a laptop.
332
+ With an empty token, the client gets the off values, and the log says "deploy to main once for
333
+ Google sign-in on this laptop". See "The development stage" below.
329
334
  - Each deploy reads the saved client on the hub with its own token. A client that is gone gets a
330
- new one, and a client on another `redirectUri` too.
335
+ new one, and a client on another `redirectUri` or of another `deploymentType` too.
331
336
  - A new client logs the link to the terms of the shared sign-in service. A refusal logs its
332
- reason, such as a host that does not serve the file, a block, or the hourly limit it reached.
337
+ reason, such as a host that does not serve the file, a block, a token of another app, or the
338
+ hourly limit it reached.
333
339
  - No failure stops a deploy or a destroy. A failed renewal keeps the saved values. Without saved
334
340
  values, `status` is `off`, and `issuer`, `clientId`, and `clientSecret` are the `no-hub` values
335
- of a checkout without a hub. A run without a GitHub OIDC token, such as on a laptop, does the
336
- same, and logs no warning.
337
- - `status` is `suspended` when the hub suspended the client. The client keeps its values.
338
- - A destroy deletes a preview client. It does nothing to a production client, because the
339
- production deployment can stay.
341
+ of a checkout without a hub. A production or preview run without a GitHub OIDC token, such as on
342
+ a laptop, does the same, and logs no warning.
343
+ - `status` is `suspended` when the hub suspended the client. The state keeps the client and its
344
+ values, so the client works again when the hub lifts the suspension.
345
+ - `Convex.SharedHubClient.variables(client)` gives `issuer`, `clientId`, and `clientSecret` for
346
+ the app's deployment: the client's values when `status` is `on`, and the `no-hub` values when it
347
+ is `off` or `suspended`. Give these to the deployment, not the outputs of the client.
348
+ - `Convex.SharedHubClient.read(id, { stage })` gives the attributes of the client `id` in another
349
+ stage of the same stack, from the state store of the stack. When that stage has no row, it gives
350
+ the off attributes and does not fail. `Convex.SharedHubClient.ref` fails when the row is
351
+ missing, and a laptop before the first deploy to main has no `prod` row.
352
+ - A destroy deletes a preview or development client. It does nothing to a production client,
353
+ because the production deployment can stay.
340
354
  - `HUB_URL` replaces `https://auth.samebase.com`, the hub that the run asks at `/hub-clients`.
341
355
 
342
- A development deployment has no stack, so the package has a command for it. Run it in the directory
343
- of the app, before the first push and at each dev start, with `CONVEX_ACCESS_TOKEN` or the Convex
344
- CLI login. It renews the client that `HUB_CLIENT_ID` names, so the client expires 14 days after the
345
- last dev start or use. A deployment without all three variables, or whose client the hub no longer
346
- has, gets a new client, and the command writes `HUB_ISSUER`, `HUB_CLIENT_ID`, and
347
- `HUB_CLIENT_SECRET` to it.
356
+ A development deployment of a team app (`Convex.HubClient`) has no stack, so the package has a
357
+ command for it. Run it in the directory of the app, before the first push and at each dev start,
358
+ with `CONVEX_ACCESS_TOKEN` or the Convex CLI login. It renews the client that `HUB_CLIENT_ID` names,
359
+ so the client expires 14 days after the last dev start or use. A deployment without all three
360
+ variables, or whose client the hub no longer has, gets a new client, and the command writes
361
+ `HUB_ISSUER`, `HUB_CLIENT_ID`, and `HUB_CLIENT_SECRET` to it.
348
362
 
349
363
  ```sh
350
364
  npx alchemy-convex hub-client --team my-team --app scout --callback-path /auth/hub/callback
@@ -355,24 +369,59 @@ For a separate API-connection callback, register another client with
355
369
  `RESOURCE_HUB_CLIENT_ID`, and `RESOURCE_HUB_CLIENT_SECRET`, preserving the sign-in client's
356
370
  variables. Each callback remains one exact registered URI.
357
371
 
358
- An app on the central identity hub gets its laptop client with `--shared`. Run it after the first
359
- push of each dev start, with `CONVEX_ACCESS_TOKEN` or the Convex CLI login of the app's team.
372
+ ### The development stage
360
373
 
361
- ```sh
362
- npx alchemy-convex hub-client --shared --stack owner/name --app app --callback-path /auth/hub/callback
374
+ A laptop is a stage `dev-<name>` of the app's stack, with its own cloud development deployment. Run
375
+ `alchemy deploy --stage dev-<name>` with `CONVEX_ACCESS_TOKEN` or the Convex CLI login of the app's
376
+ team, before `convex dev`. The stage has the same three rows as a preview:
377
+
378
+ ```ts
379
+ // The stage is dev-<name>. `project` is the project of the deployment that the Convex CLI selects.
380
+ const prod = Convex.SharedHubClient.read("HubClient", { stage: "prod" });
381
+ const deployment =
382
+ yield *
383
+ Convex.Deployment("Deployment", {
384
+ project,
385
+ type: "dev",
386
+ name: stage,
387
+ source: "./convex",
388
+ env: { SAMEBASE_REPOSITORY_ID: prod.repository },
389
+ }).pipe(adopt(true));
390
+ const hub =
391
+ yield *
392
+ Convex.SharedHubClient("HubClient", {
393
+ app: "scout",
394
+ redirectUri: Output.interpolate`${deployment.siteUrl}/auth/hub/callback`,
395
+ deploymentType: "development",
396
+ initialAccessToken: prod.initialAccessToken,
397
+ });
398
+ const variables = Convex.SharedHubClient.variables(hub);
399
+ yield *
400
+ Convex.Deployment("Site", {
401
+ project,
402
+ type: "dev",
403
+ name: stage,
404
+ env: {
405
+ HUB_ISSUER: variables.issuer,
406
+ HUB_CLIENT_ID: variables.clientId,
407
+ HUB_CLIENT_SECRET: variables.clientSecret,
408
+ },
409
+ }).pipe(adopt(true));
363
410
  ```
364
411
 
365
- The team is `--team`, else `CONVEX_TEAM_ID`, else the team of the cloud development deployment that
366
- `.env.local` selects. A linked git worktree reads the `.env.local` of the main checkout. The command
367
- renews the client that `HUB_REGISTRATION_URI` and `HUB_REGISTRATION_TOKEN` name, only when
368
- `HUB_ISSUER` is the hub that it asks (`HUB_URL`, else `https://auth.samebase.com`), the client ID
369
- and the secret are not `no-hub`, and the hub answers exactly the current callback. Else, it reads
370
- the initial access token from the `HubClient` row of stage `prod` of the stack, and asks the hub for
371
- a development client on the deployment's `CONVEX_SITE_URL`. It writes `HUB_ISSUER`, `HUB_CLIENT_ID`,
372
- `HUB_CLIENT_SECRET`, `HUB_REGISTRATION_URI`, and `HUB_REGISTRATION_TOKEN` in one `env set --force`,
373
- and `SAMEBASE_REPOSITORY_ID` on an https site. A client that the hub stopped gets the off values.
374
- Without a token, after a refusal, or after a failure, it prints one line, and the variables stay. It
375
- never stops a dev start.
412
+ The imports are `alchemy/AdoptPolicy` for `adopt` and `alchemy/Output` for `Output`.
413
+
414
+ 1. `read` gives the initial access token and the repository id from the `HubClient` row of stage
415
+ `prod`. Before the first deploy to main, they are empty.
416
+ 2. "Deployment" finds the development deployment by its reference, or creates it. It sets
417
+ `SAMEBASE_REPOSITORY_ID`, which the site serves as `/samebase-verification.json`, and pushes.
418
+ 3. "HubClient" asks the hub for a development client on the deployment's site URL with the token.
419
+ Each later run renews it. A development client expires 14 days after its last run or use.
420
+ 4. "Site" writes the three variables. It removes only a name that leaves its own `env`, so
421
+ `SAMEBASE_REPOSITORY_ID` stays.
422
+
423
+ Both deployment rows keep the default removal policy `retain`. `alchemy destroy --stage dev-<name>`
424
+ deletes the development client and keeps the deployment.
376
425
 
377
426
  ## Pull request comment
378
427
 
@@ -29,8 +29,7 @@ import * as Stream from "effect/Stream";
29
29
  import { fileURLToPath } from "node:url";
30
30
  import * as Scope from "effect/Scope";
31
31
  import { STATE_STORE_VERSION, State, StateStoreError, encodeState, reviveState } from "alchemy/State";
32
- import { execFile, execFileSync } from "node:child_process";
33
- import * as Cause from "effect/Cause";
32
+ import { execFile } from "node:child_process";
34
33
  //#region src/Credentials.ts
35
34
  var Credentials = class extends Context.Service()("@samebase/alchemy-convex/Credentials") {};
36
35
  /** Environment variable for the access token, read by {@link fromConfig}. */
@@ -1576,7 +1575,7 @@ const post = (url, headers, body) => Effect.tryPromise({
1576
1575
  schedule: Schedule.exponential("100 millis").pipe(Schedule.jittered),
1577
1576
  times: 5
1578
1577
  }));
1579
- const decode$1 = (schema, what) => (value) => Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => failure(`bad ${what}`)));
1578
+ const decode = (schema, what) => (value) => Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => failure(`bad ${what}`)));
1580
1579
  /** The reply of `state:sealing`. */
1581
1580
  const Sealing = Schema.Struct({
1582
1581
  current: Schema.NullOr(Schema.String),
@@ -1588,7 +1587,7 @@ const makeFunctionApi = (url, key) => ({ call: (path, args) => post(`${url}/api/
1588
1587
  path,
1589
1588
  args,
1590
1589
  format: "json"
1591
- }).pipe(Effect.flatMap(decode$1(Reply, `reply of ${path}`))) });
1590
+ }).pipe(Effect.flatMap(decode(Reply, `reply of ${path}`))) });
1592
1591
  /** The value of a reply, or the Convex message, which names the function and the cause, such as a missing key action. */
1593
1592
  const valueOf = (reply) => reply.status === "success" ? Effect.succeed(reply.value) : Effect.fail(failure(reply.errorMessage.trim()));
1594
1593
  /** The functions of the store with Alchemy's encoding: the JSON text of encodeState. */
@@ -1604,8 +1603,8 @@ const remoteOf = (api, record) => {
1604
1603
  run: currentRun
1605
1604
  });
1606
1605
  });
1607
- const strings = (name, args) => run(name, args).pipe(Effect.flatMap(decode$1(Schema.Array(Schema.String), name)));
1608
- const optional = (name, args) => run(name, args).pipe(Effect.flatMap(decode$1(Schema.NullOr(Schema.String), name)));
1606
+ const strings = (name, args) => run(name, args).pipe(Effect.flatMap(decode(Schema.Array(Schema.String), name)));
1607
+ const optional = (name, args) => run(name, args).pipe(Effect.flatMap(decode(Schema.NullOr(Schema.String), name)));
1609
1608
  const encode = (value) => JSON.stringify(encodeState(value));
1610
1609
  return {
1611
1610
  listStacks: () => strings("stacks", {}),
@@ -1650,7 +1649,7 @@ const remoteOf = (api, record) => {
1650
1649
  };
1651
1650
  };
1652
1651
  /** The version that the store answers, or undefined when the deployment has no `state:version`. */
1653
- const storeVersion = (api) => api.call("state:version", {}).pipe(Effect.flatMap((reply) => reply.status === "error" && MISSING_FUNCTION.test(reply.errorMessage) ? Effect.succeed(void 0) : valueOf(reply).pipe(Effect.flatMap(decode$1(Schema.Number, "store version")))));
1652
+ const storeVersion = (api) => api.call("state:version", {}).pipe(Effect.flatMap((reply) => reply.status === "error" && MISSING_FUNCTION.test(reply.errorMessage) ? Effect.succeed(void 0) : valueOf(reply).pipe(Effect.flatMap(decode(Schema.Number, "store version")))));
1654
1653
  /**
1655
1654
  * The functions directory of the store app, `src/store/convex` of this
1656
1655
  * package. This file is `src/State.ts` in a checkout and a chunk in `lib/`
@@ -1695,7 +1694,7 @@ const pushStore = (options, deployment) => Effect.gen(function* () {
1695
1694
  * it never reads a key back.
1696
1695
  */
1697
1696
  const ensureSealed = (options, deployment, functions) => Effect.gen(function* () {
1698
- const { current, sealed } = yield* functions.call("state:sealing", {}).pipe(Effect.flatMap(valueOf), Effect.flatMap(decode$1(Sealing, "state:sealing")));
1697
+ const { current, sealed } = yield* functions.call("state:sealing", {}).pipe(Effect.flatMap(valueOf), Effect.flatMap(decode(Sealing, "state:sealing")));
1699
1698
  if (current === null) {
1700
1699
  const keyId = newKeyId();
1701
1700
  yield* withPushKey(options.api, deployment.name, PUSH_KEY_ACTIONS, (key) => writeVariables(deployment.deploymentUrl, key, [{
@@ -1705,7 +1704,7 @@ const ensureSealed = (options, deployment, functions) => Effect.gen(function* ()
1705
1704
  yield* functions.call("state:chooseKey", { keyId }).pipe(Effect.flatMap(valueOf));
1706
1705
  }
1707
1706
  if (sealed) return;
1708
- const { failed } = yield* functions.call("state:reseal", {}).pipe(Effect.flatMap(valueOf), Effect.flatMap(decode$1(Resealed, "state:reseal")));
1707
+ const { failed } = yield* functions.call("state:reseal", {}).pipe(Effect.flatMap(valueOf), Effect.flatMap(decode(Resealed, "state:reseal")));
1709
1708
  if (failed.length > 0) yield* Effect.logWarning(`Convex state: the store could not seal ${failed.join(", ")}. The next run tries again.`);
1710
1709
  });
1711
1710
  /**
@@ -1988,398 +1987,6 @@ const registerDevelopmentClient = (options) => Effect.gen(function* () {
1988
1987
  return callbackUrl;
1989
1988
  });
1990
1989
  //#endregion
1991
- //#region src/SharedHubClient.ts
1992
- /** A hung service must not hold a deploy: the time limit of one request. */
1993
- const REQUEST_TIMEOUT_MS = 3e4;
1994
- /** The variables of a deployment without a hub client, as the starter writes them. */
1995
- const OFF_VALUES = {
1996
- issuer: "http://127.0.0.1:1",
1997
- clientId: "no-hub",
1998
- clientSecret: "no-hub"
1999
- };
2000
- /** The hub that a run asks, which is also its issuer. */
2001
- const hubUrl = () => process.env["HUB_URL"] ?? "https://auth.samebase.com";
2002
- /** Where a run asks for a new client. */
2003
- const hubClientsUrl = () => `${hubUrl()}/hub-clients`;
2004
- const SharedHubClient = Resource("IdentityHub.SharedClient");
2005
- /** One request gave no usable answer. The reason holds no secret. */
2006
- var SharedHubClientFailed = class extends Schema.TaggedError()("SharedHubClientFailed", { reason: Schema.String }) {};
2007
- const OidcToken = Schema.Struct({ value: Schema.NonEmptyString });
2008
- /** The 201 answer of `POST /hub-clients`. */
2009
- const NewClient = Schema.Struct({
2010
- issuer: Schema.NonEmptyString,
2011
- client_id: Schema.NonEmptyString,
2012
- client_secret: Schema.NonEmptyString,
2013
- registration_access_token: Schema.NonEmptyString,
2014
- registration_client_uri: Schema.NonEmptyString,
2015
- redirect_uri: Schema.NonEmptyString,
2016
- /** The terms of the shared sign-in service, which the log shows once, for the new client. */
2017
- terms: Schema.NonEmptyString,
2018
- /** Only in the answer to a production registration with the GitHub token. */
2019
- initial_access_token: Schema.optionalKey(Schema.NonEmptyString),
2020
- repository: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^\d+$/)))
2021
- });
2022
- /** The other answers of `POST /hub-clients`, by status. */
2023
- const REFUSALS = {
2024
- 400: "the identity hub refused the request: the return address must be https on a DNS name, and the app name at most 64 characters",
2025
- 401: "the identity hub refused the GitHub OIDC token",
2026
- 403: "the identity hub does not allow this repository or return address",
2027
- 404: "the identity hub has no /hub-clients",
2028
- 429: "the identity hub limits new clients",
2029
- 502: "GitHub's keys are not available to the identity hub"
2030
- };
2031
- /** The reasons of a 403 that a log line shows. Any other text of the answer is never logged. */
2032
- const NOT_ALLOWED = /* @__PURE__ */ new Map([
2033
- ["paused", "the identity hub pauses new clients of third-party apps"],
2034
- ["run_not_allowed", "the identity hub gives clients only to runs of main and of pull requests"],
2035
- ["blocked", "Samebase blocked this repository or its owner"],
2036
- ["suspended", "the identity hub suspended this repository"],
2037
- ["too_many_apps", "the repository has as many apps on the identity hub as it may"],
2038
- ["too_many_clients", "the app has as many clients on the identity hub as it may"],
2039
- ["app_taken", "the app's name belongs to another app on the identity hub"],
2040
- ["host_unreachable", "the identity hub could not reach the host of the return address"],
2041
- ["verification_file_missing", "the host of the return address does not serve /samebase-verification.json"],
2042
- ["verification_file_invalid", "/samebase-verification.json on the host holds no repository id"],
2043
- ["repository_mismatch", "/samebase-verification.json on the host names another repository"],
2044
- ["app_mismatch", "the initial access token belongs to another app on the identity hub"]
2045
- ]);
2046
- /** The windows of a 429, by the name that the answer gives. */
2047
- const RATE_LIMITED = /* @__PURE__ */ new Map([
2048
- ["repository", "the identity hub gives one repository at most 30 new clients an hour"],
2049
- ["owner", "the identity hub gives one owner at most 100 new clients an hour"],
2050
- ["new_owners", "the identity hub takes at most 50 new owners an hour"]
2051
- ]);
2052
- const Refusal = Schema.Struct({ reason: Schema.String });
2053
- const RateLimited = Schema.Struct({ window: Schema.String });
2054
- /** The text of a refusal: a known reason of a 403 or window of a 429, else the text of its status. */
2055
- const refusalOf = (status, body) => {
2056
- return (status === 403 ? NOT_ALLOWED.get(Option.getOrUndefined(Schema.decodeUnknownOption(Refusal)(body))?.reason ?? "") : status === 429 ? RATE_LIMITED.get(Option.getOrUndefined(Schema.decodeUnknownOption(RateLimited)(body))?.window ?? "") : void 0) ?? REFUSALS[status] ?? "the identity hub refused the request";
2057
- };
2058
- const send = (who, url, token, init) => Effect.tryPromise({
2059
- try: async () => {
2060
- const response = await fetch(url, {
2061
- ...init,
2062
- headers: {
2063
- Authorization: `Bearer ${token}`,
2064
- "Content-Type": "application/json"
2065
- },
2066
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
2067
- });
2068
- const body = await response.json().catch(() => void 0);
2069
- return {
2070
- status: response.status,
2071
- body
2072
- };
2073
- },
2074
- catch: () => new SharedHubClientFailed({ reason: `${who} gave no answer` })
2075
- });
2076
- const decode = (schema, who) => (value) => Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => new SharedHubClientFailed({ reason: `${who} gave a bad answer` })));
2077
- /** The OIDC token of this run, read right before the call, because it lives for minutes only. */
2078
- const readOidcToken = (requestUrl, requestToken) => Effect.gen(function* () {
2079
- const { body, status } = yield* send("GitHub", `${requestUrl}&audience=samebase`, requestToken, { method: "GET" });
2080
- if (status !== 200) return yield* new SharedHubClientFailed({ reason: `GitHub gave no OIDC token (HTTP ${status})` });
2081
- return (yield* decode(OidcToken, "GitHub")(body)).value;
2082
- });
2083
- const requestNewClient = (props, requestUrl, requestToken) => Effect.gen(function* () {
2084
- const token = yield* readOidcToken(requestUrl, requestToken);
2085
- const { body, status } = yield* send("The identity hub", hubClientsUrl(), token, {
2086
- method: "POST",
2087
- body: JSON.stringify({
2088
- app: props.app,
2089
- redirectUri: props.redirectUri,
2090
- softwareVersion: props.softwareVersion ?? "unknown"
2091
- })
2092
- });
2093
- if (status !== 201) return yield* new SharedHubClientFailed({ reason: `${refusalOf(status, body)} (HTTP ${status})` });
2094
- const client = yield* decode(NewClient, "The identity hub")(body);
2095
- yield* Effect.logInfo(`Convex.SharedHubClient: ${props.app} on ${client.redirect_uri} has a new client of the identity hub. Its use is under ${client.terms}.`);
2096
- return {
2097
- status: "on",
2098
- issuer: client.issuer,
2099
- clientId: client.client_id,
2100
- clientSecret: Redacted.make(client.client_secret),
2101
- registrationAccessToken: Redacted.make(client.registration_access_token),
2102
- registrationClientUri: client.registration_client_uri,
2103
- deploymentType: props.deploymentType,
2104
- redirectUri: client.redirect_uri,
2105
- initialAccessToken: Redacted.make(client.initial_access_token ?? ""),
2106
- repository: client.repository ?? ""
2107
- };
2108
- });
2109
- /** A new client, or `saved`, or the off values. `saved` holds no off values. */
2110
- const newClient = (props) => {
2111
- const label = `Convex.SharedHubClient: ${props.app} on ${props.redirectUri} deploys without the hub`;
2112
- const off = {
2113
- status: "off",
2114
- issuer: OFF_VALUES.issuer,
2115
- clientId: OFF_VALUES.clientId,
2116
- clientSecret: Redacted.make(OFF_VALUES.clientSecret),
2117
- registrationAccessToken: Redacted.make(""),
2118
- registrationClientUri: "",
2119
- deploymentType: props.deploymentType,
2120
- redirectUri: "",
2121
- initialAccessToken: Redacted.make(""),
2122
- repository: ""
2123
- };
2124
- const requestUrl = process.env["ACTIONS_ID_TOKEN_REQUEST_URL"];
2125
- const requestToken = process.env["ACTIONS_ID_TOKEN_REQUEST_TOKEN"];
2126
- if (!requestUrl || !requestToken) return (process.env["GITHUB_ACTIONS"] === "true" ? Effect.logWarning(`${label}: this workflow needs the permission id-token: write.`) : Effect.logInfo(`${label}: this run has no GitHub OIDC token.`)).pipe(Effect.as(off));
2127
- return requestNewClient(props, requestUrl, requestToken).pipe(Effect.catchTag("SharedHubClientFailed", (error) => Effect.logWarning(`${label}: ${error.reason}. The next deploy asks again.`).pipe(Effect.as(off))));
2128
- };
2129
- /**
2130
- * The token fields of saved attributes. A row of 0.25 and earlier has neither, and they stay empty
2131
- * until the next production registration.
2132
- */
2133
- const StoredToken = Schema.Struct({
2134
- initialAccessToken: Schema.optionalKey(Schema.Redacted(Schema.String)),
2135
- repository: Schema.optionalKey(Schema.String)
2136
- });
2137
- /** The client after a read on the hub, or undefined when the hub says that the client is gone. */
2138
- const renew = (stored) => {
2139
- const { initialAccessToken = Redacted.make(""), repository = "" } = Schema.decodeUnknownSync(StoredToken)(stored);
2140
- const saved = {
2141
- ...stored,
2142
- initialAccessToken,
2143
- repository
2144
- };
2145
- return Effect.gen(function* () {
2146
- const { status } = yield* send("The identity hub", saved.registrationClientUri, Redacted.value(saved.registrationAccessToken), { method: "GET" });
2147
- switch (status) {
2148
- case 200: return {
2149
- ...saved,
2150
- status: "on"
2151
- };
2152
- case 401: return;
2153
- case 403:
2154
- yield* Effect.logWarning(`Convex.SharedHubClient: the hub suspended the client of ${saved.redirectUri}. It keeps its values, and nobody can sign in with it.`);
2155
- return {
2156
- ...saved,
2157
- status: "suspended"
2158
- };
2159
- default: return yield* new SharedHubClientFailed({ reason: `The identity hub answered HTTP ${status}` });
2160
- }
2161
- }).pipe(Effect.catchTag("SharedHubClientFailed", (error) => Effect.logWarning(`Convex.SharedHubClient: ${saved.redirectUri} keeps the saved client: ${error.reason}.`).pipe(Effect.as(saved))));
2162
- };
2163
- const SharedHubClientProvider = () => Provider.succeed(SharedHubClient, {
2164
- diff: ({ news }) => Effect.succeed(isResolved(news) ? { action: "update" } : void 0),
2165
- read: ({ output }) => Effect.succeed(output),
2166
- reconcile: Effect.fn(function* ({ news, output }) {
2167
- const saved = output?.status === "off" ? void 0 : output;
2168
- return (saved?.redirectUri === news.redirectUri ? yield* renew(saved) : void 0) ?? (yield* newClient(news));
2169
- }),
2170
- delete: ({ output }) => output.deploymentType === "production" || output.status === "off" ? Effect.void : Effect.gen(function* () {
2171
- const { status } = yield* send("The identity hub", output.registrationClientUri, Redacted.value(output.registrationAccessToken), { method: "DELETE" });
2172
- if (status !== 204 && status !== 401) return yield* new SharedHubClientFailed({ reason: `The identity hub answered HTTP ${status}` });
2173
- }).pipe(Effect.catchTag("SharedHubClientFailed", (error) => Effect.logWarning(`Convex.SharedHubClient: the client of ${output.redirectUri} stays on the hub until its removal date: ${error.reason}.`)))
2174
- });
2175
- /** The logical id of the client in the stack of the starter. */
2176
- const CLIENT_FQN = "HubClient";
2177
- /** The one action that `state:get` needs. */
2178
- const READ_KEY_ACTIONS = ["deployment:functions:runInternalQueries"];
2179
- /** `CONVEX_DEPLOYMENT` of `.env.local` for a cloud development deployment, such as `dev:happy-otter-123`. */
2180
- const CLOUD_DEPLOYMENT = /^CONVEX_DEPLOYMENT=(?:dev:)?([a-z0-9]+(?:-[a-z0-9]+)*)[ \t]*(?:#.*)?$/m;
2181
- /** The client row of stage `prod` with a token. A row without one fails to decode. */
2182
- const SavedToken = Schema.Struct({
2183
- resourceType: Schema.Literal("IdentityHub.SharedClient"),
2184
- attr: Schema.Struct({
2185
- initialAccessToken: Schema.Redacted(Schema.NonEmptyString),
2186
- repository: Schema.NonEmptyString
2187
- })
2188
- });
2189
- /** The part of a client's own record (RFC 7592) that a renewal compares. */
2190
- const ClientRecord = Schema.Struct({ redirect_uris: Schema.Array(Schema.String) });
2191
- /**
2192
- * The directory whose `.env.local` selects the cloud development deployment of `cwd`. A linked git
2193
- * worktree runs a local backend, so the main checkout, the parent of the common git directory,
2194
- * selects it.
2195
- */
2196
- const selectingDirectory = (cwd) => Effect.try({
2197
- try: () => execFileSync("git", [
2198
- "rev-parse",
2199
- "--path-format=absolute",
2200
- "--git-dir",
2201
- "--git-common-dir",
2202
- "--show-prefix"
2203
- ], {
2204
- cwd,
2205
- encoding: "utf8",
2206
- stdio: [
2207
- "ignore",
2208
- "pipe",
2209
- "ignore"
2210
- ]
2211
- }).split(/\r?\n/),
2212
- catch: () => void 0
2213
- }).pipe(Effect.map(([gitDir, commonDir, prefix = ""]) => gitDir === void 0 || commonDir === void 0 || gitDir === commonDir ? cwd : join(dirname(commonDir), prefix)), Effect.orElseSucceed(() => cwd));
2214
- /** The team of the cloud development deployment that `.env.local` selects, or undefined. */
2215
- const teamOfSelectedDeployment = (api, cwd) => Effect.gen(function* () {
2216
- const directory = yield* selectingDirectory(cwd);
2217
- const text = yield* Effect.tryPromise(() => readFile(join(directory, ".env.local"), "utf8")).pipe(Effect.orElseSucceed(() => ""));
2218
- const name = CLOUD_DEPLOYMENT.exec(text)?.[1];
2219
- if (name === void 0) return void 0;
2220
- const { projectId } = yield* api.request("get deployment", (client) => client.GET("/deployments/{deployment_name}", { params: { path: { deployment_name: name } } })).pipe(Effect.flatMap(Schema.decodeUnknownEffect(DeploymentRow$1)));
2221
- return (yield* getProjectById(api, projectId))?.teamId;
2222
- });
2223
- /**
2224
- * The initial access token and the repository id in the client row of stage `prod` of `stack`, or
2225
- * undefined. It reads the row with the store's own `state:get` and a key that runs internal queries
2226
- * only. It never builds `Convex.state()`, which can create or upgrade the store.
2227
- */
2228
- const readSavedToken = (api, team, stack) => Effect.gen(function* () {
2229
- const project = yield* findProjectByName(api, yield* api.resolveTeamId(team), STORE_PROJECT_NAME);
2230
- const deployment = project === void 0 ? void 0 : yield* prodDeploymentOf(api, project.id);
2231
- if (deployment === void 0) return void 0;
2232
- const reply = yield* withPushKey(api, deployment.name, READ_KEY_ACTIONS, (key) => makeFunctionApi(deployment.deploymentUrl, key).call("state:get", {
2233
- stack,
2234
- stage: "prod",
2235
- fqn: CLIENT_FQN
2236
- }));
2237
- if (reply.status === "error") return yield* new SharedHubClientFailed({ reason: "the state store refused the read" });
2238
- const text = yield* decode(Schema.NullOr(Schema.String), "The state store")(reply.value);
2239
- if (text === null) return void 0;
2240
- const row = yield* Effect.try({
2241
- try: () => JSON.parse(text, reviveState),
2242
- catch: () => new SharedHubClientFailed({ reason: "the state store gave a bad row" })
2243
- });
2244
- return Option.getOrUndefined(Schema.decodeUnknownOption(SavedToken)(row))?.attr;
2245
- });
2246
- /** Replaces any failure or defect of a step with a fixed text, which quotes nothing. */
2247
- const failsWith = (reason) => (effect) => effect.pipe(Effect.catchCause(() => Effect.fail(new SharedHubClientFailed({ reason }))));
2248
- /** What the hub says about the client that the variables name. */
2249
- const readClient = (uri, token, callbackUrl) => Effect.gen(function* () {
2250
- const { body, status } = yield* send("The identity hub", uri, token, { method: "GET" });
2251
- switch (status) {
2252
- case 200: {
2253
- const [only, ...more] = (yield* decode(ClientRecord, "The identity hub")(body)).redirect_uris;
2254
- return only === callbackUrl && more.length === 0 ? "renewed" : "moved";
2255
- }
2256
- case 401: return "gone";
2257
- case 403: return "stopped";
2258
- default: return yield* new SharedHubClientFailed({ reason: `the identity hub answered HTTP ${status}` });
2259
- }
2260
- });
2261
- /**
2262
- * `alchemy-convex hub-client --shared`: a development client of an app on the central identity hub,
2263
- * for `pnpm run dev` on a laptop, with the initial access token that the stack saved at the
2264
- * production registration (RFC 133). Run it after the first push of each dev start. Answers the one
2265
- * line to print. It fails only for a defect: a refusal, a failed request, or no token keeps the
2266
- * variables and answers why.
2267
- *
2268
- * 1. The team: `team`, else `CONVEX_TEAM_ID`, else the team of the selected cloud development
2269
- * deployment.
2270
- * 2. The five variables name a client of this hub (`<prefix>_ISSUER` is `HUB_URL` or
2271
- * `https://auth.samebase.com`) with a real client ID and secret: the hub renews it on a read with
2272
- * its own token (RFC 7592) when its `redirect_uris` are exactly the current callback. A client on
2273
- * another address, or one that the hub no longer has, gets a new client. A client that the hub
2274
- * stopped gets the off values.
2275
- * 3. The token from the client row of stage `prod`, the repository id on an https site, and a new
2276
- * client, in one `env set --force`.
2277
- *
2278
- * The secret and both tokens reach the Convex CLI on standard input, never in its arguments, and no
2279
- * line holds them.
2280
- */
2281
- const registerSharedDevelopmentClient = (options) => Effect.gen(function* () {
2282
- const api = yield* ManagementApi;
2283
- /** One call of the Convex CLI. Its error can quote the deployment, so it gives a fixed text. */
2284
- const convex = (args, input) => Effect.try({
2285
- try: () => options.convex(args, input),
2286
- catch: () => new SharedHubClientFailed({ reason: `\`convex ${args.slice(0, 2).join(" ")}\` failed` })
2287
- });
2288
- const named = options.team ?? process.env["CONVEX_TEAM_ID"];
2289
- const team = named !== void 0 && named !== "" ? named : yield* teamOfSelectedDeployment(api, options.cwd).pipe(failsWith("the Convex API could not read the selected development deployment"));
2290
- if (team === void 0) return "No Convex team for Google sign-in on this laptop: set CONVEX_TEAM_ID, or select a cloud development deployment.";
2291
- const prefix = options.envPrefix ?? "HUB";
2292
- const siteUrl = yield* decode(Schema.fromJsonString(Schema.String), "The deployment")(yield* convex([
2293
- "run",
2294
- "--inline-query",
2295
- "process.env.CONVEX_SITE_URL"
2296
- ]));
2297
- const callbackUrl = `${siteUrl.replace(/\/$/, "")}${options.callbackPath}`;
2298
- const names = (yield* convex([
2299
- "env",
2300
- "list",
2301
- "--names-only"
2302
- ])).split(/\r?\n/);
2303
- const variable = (name) => `${prefix}_${name}`;
2304
- const tokenVariable = variable("REGISTRATION_TOKEN");
2305
- if ([
2306
- "ISSUER",
2307
- "CLIENT_ID",
2308
- "CLIENT_SECRET",
2309
- "REGISTRATION_URI",
2310
- "REGISTRATION_TOKEN"
2311
- ].every((name) => names.includes(variable(name))) && (yield* convex([
2312
- "env",
2313
- "get",
2314
- variable("ISSUER")
2315
- ])) === hubUrl() && (yield* convex([
2316
- "env",
2317
- "get",
2318
- variable("CLIENT_ID")
2319
- ])) !== OFF_VALUES.clientId && ![OFF_VALUES.clientSecret, ""].includes(yield* convex([
2320
- "env",
2321
- "get",
2322
- variable("CLIENT_SECRET")
2323
- ]))) {
2324
- const client = yield* readClient(yield* convex([
2325
- "env",
2326
- "get",
2327
- variable("REGISTRATION_URI")
2328
- ]), yield* convex([
2329
- "env",
2330
- "get",
2331
- tokenVariable
2332
- ]), callbackUrl);
2333
- if (client === "renewed") return "Hub client renewed.";
2334
- if (client === "stopped") {
2335
- yield* convex([
2336
- "env",
2337
- "set",
2338
- "--force"
2339
- ], `${prefix}_ISSUER='${OFF_VALUES.issuer}'\n${prefix}_CLIENT_ID='${OFF_VALUES.clientId}'\n${prefix}_CLIENT_SECRET='${OFF_VALUES.clientSecret}'\n`);
2340
- yield* convex([
2341
- "env",
2342
- "remove",
2343
- variable("REGISTRATION_URI")
2344
- ]);
2345
- yield* convex([
2346
- "env",
2347
- "remove",
2348
- tokenVariable
2349
- ]);
2350
- return "The identity hub stopped the hub client of this laptop (HTTP 403), so the laptop has guest sign-in.";
2351
- }
2352
- }
2353
- const saved = yield* readSavedToken(api, team, options.stack).pipe(failsWith("the state store of the team could not be read"));
2354
- if (saved === void 0) return "Deploy to main once for Google sign-in on this laptop.";
2355
- if (new URL(siteUrl).protocol === "https:") yield* convex([
2356
- "env",
2357
- "set",
2358
- "SAMEBASE_REPOSITORY_ID",
2359
- saved.repository
2360
- ]);
2361
- const { body, status } = yield* send("The identity hub", hubClientsUrl(), Redacted.value(saved.initialAccessToken), {
2362
- method: "POST",
2363
- body: JSON.stringify({
2364
- app: options.app,
2365
- redirectUri: callbackUrl,
2366
- softwareVersion: new URL(siteUrl).host
2367
- })
2368
- });
2369
- if (status !== 201) return `The hub client keeps its values: ${status === 401 ? "the identity hub refused the initial access token of the app" : refusalOf(status, body)} (HTTP ${status}).`;
2370
- const client = yield* decode(NewClient, "The identity hub")(body);
2371
- yield* convex([
2372
- "env",
2373
- "set",
2374
- "--force"
2375
- ], `${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`);
2376
- return `Hub client registered for ${callbackUrl}.`;
2377
- }).pipe(Effect.catchCause((cause) => {
2378
- const error = Cause.squash(cause);
2379
- const reason = error instanceof SharedHubClientFailed ? error.reason : "an unexpected failure";
2380
- return Effect.succeed(`The hub client keeps its values: ${reason}.`);
2381
- }));
2382
- //#endregion
2383
- export { ConvexAppNotFound as A, ACCESS_TOKEN_ENV as B, AmbiguousDeployment as C, DeploymentResource as D, DeploymentProvider as E, installedCli as F, fromValues as G, CredentialsError as H, parseDeployOutput as I, ConvexApiError as L, EnvironmentNeedsDeployKey as M, PushTargetMismatch as N, ProductionDeploymentNotFound as O, VariablesOwnedBySomeoneElse as P, ManagementApi as R, AbsoluteRootDir as S, DeploymentIdentityChange as T, DEPLOY_KEY_ENV as U, Credentials as V, fromConfig as W, Project as _, HubClient as a, ProjectProvider as b, registerDevelopmentClient as c, RUN_RECORD_KEY as d, RunRecordError as f, CreatedProjectNotFound as g, AmbiguousProject as h, HUB_PROJECT_NAME as i, ConvexCliNotFound as j, CodePushFailed as k, STORE_PROJECT_NAME as l, withRunRecord as m, SharedHubClientProvider as n, HubClientFailed as o, runRecord as p, registerSharedDevelopmentClient as r, HubClientProvider as s, SharedHubClient as t, state as u, ProjectDeploymentChange as v, Deployment as w, ProjectTeamChange as x, ProjectNotFound as y, ManagementApiLive as z };
1990
+ export { PushTargetMismatch as A, DEPLOY_KEY_ENV as B, DeploymentProvider as C, ConvexAppNotFound as D, CodePushFailed as E, ManagementApi as F, fromValues as H, ManagementApiLive as I, ACCESS_TOKEN_ENV as L, installedCli as M, parseDeployOutput as N, ConvexCliNotFound as O, ConvexApiError as P, Credentials as R, DeploymentIdentityChange as S, ProductionDeploymentNotFound as T, fromConfig as V, ProjectProvider as _, registerDevelopmentClient as a, AmbiguousDeployment as b, RUN_RECORD_KEY as c, withRunRecord as d, AmbiguousProject as f, ProjectNotFound as g, ProjectDeploymentChange as h, HubClientProvider as i, VariablesOwnedBySomeoneElse as j, EnvironmentNeedsDeployKey as k, RunRecordError as l, Project as m, HubClient as n, STORE_PROJECT_NAME as o, CreatedProjectNotFound as p, HubClientFailed as r, state as s, HUB_PROJECT_NAME as t, runRecord as u, ProjectTeamChange as v, DeploymentResource as w, Deployment as x, AbsoluteRootDir as y, CredentialsError as z };
2384
1991
 
2385
- //# sourceMappingURL=SharedHubClient-D-FJJUCZ.js.map
1992
+ //# sourceMappingURL=HubClient-Bs5_j18U.js.map