@samebase/alchemy-convex 0.25.0 → 0.26.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
@@ -320,7 +320,12 @@ call.
320
320
  numeric GitHub id of the repository, `{ "repository": "123" }`, which
321
321
  `gh api repos/<owner>/<name> --jq .id` prints. The hub reads it before it registers the client, so
322
322
  the site must be live first. Outputs `status`, `issuer`, `clientId`, `clientSecret`,
323
- `registrationAccessToken`, `registrationClientUri`, `deploymentType`, and `redirectUri`.
323
+ `registrationAccessToken`, `registrationClientUri`, `deploymentType`, `redirectUri`,
324
+ `initialAccessToken`, and `repository`.
325
+ - 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.
324
329
  - Each deploy reads the saved client on the hub with its own token. A client that is gone gets a
325
330
  new one, and a client on another `redirectUri` too.
326
331
  - A new client logs the link to the terms of the shared sign-in service. A refusal logs its
@@ -350,6 +355,25 @@ For a separate API-connection callback, register another client with
350
355
  `RESOURCE_HUB_CLIENT_ID`, and `RESOURCE_HUB_CLIENT_SECRET`, preserving the sign-in client's
351
356
  variables. Each callback remains one exact registered URI.
352
357
 
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.
360
+
361
+ ```sh
362
+ npx alchemy-convex hub-client --shared --stack owner/name --app app --callback-path /auth/hub/callback
363
+ ```
364
+
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.
376
+
353
377
  ## Pull request comment
354
378
 
355
379
  `alchemy-convex stack-comment` keeps one comment on a pull request. The comment has a card for each
@@ -29,7 +29,8 @@ 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 } from "node:child_process";
32
+ import { execFile, execFileSync } from "node:child_process";
33
+ import * as Cause from "effect/Cause";
33
34
  //#region src/Credentials.ts
34
35
  var Credentials = class extends Context.Service()("@samebase/alchemy-convex/Credentials") {};
35
36
  /** Environment variable for the access token, read by {@link fromConfig}. */
@@ -1575,7 +1576,7 @@ const post = (url, headers, body) => Effect.tryPromise({
1575
1576
  schedule: Schedule.exponential("100 millis").pipe(Schedule.jittered),
1576
1577
  times: 5
1577
1578
  }));
1578
- const decode = (schema, what) => (value) => Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => failure(`bad ${what}`)));
1579
+ const decode$1 = (schema, what) => (value) => Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => failure(`bad ${what}`)));
1579
1580
  /** The reply of `state:sealing`. */
1580
1581
  const Sealing = Schema.Struct({
1581
1582
  current: Schema.NullOr(Schema.String),
@@ -1587,7 +1588,7 @@ const makeFunctionApi = (url, key) => ({ call: (path, args) => post(`${url}/api/
1587
1588
  path,
1588
1589
  args,
1589
1590
  format: "json"
1590
- }).pipe(Effect.flatMap(decode(Reply, `reply of ${path}`))) });
1591
+ }).pipe(Effect.flatMap(decode$1(Reply, `reply of ${path}`))) });
1591
1592
  /** The value of a reply, or the Convex message, which names the function and the cause, such as a missing key action. */
1592
1593
  const valueOf = (reply) => reply.status === "success" ? Effect.succeed(reply.value) : Effect.fail(failure(reply.errorMessage.trim()));
1593
1594
  /** The functions of the store with Alchemy's encoding: the JSON text of encodeState. */
@@ -1603,8 +1604,8 @@ const remoteOf = (api, record) => {
1603
1604
  run: currentRun
1604
1605
  });
1605
1606
  });
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)));
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)));
1608
1609
  const encode = (value) => JSON.stringify(encodeState(value));
1609
1610
  return {
1610
1611
  listStacks: () => strings("stacks", {}),
@@ -1649,7 +1650,7 @@ const remoteOf = (api, record) => {
1649
1650
  };
1650
1651
  };
1651
1652
  /** The version that the store answers, or undefined when the deployment has no `state: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")))));
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")))));
1653
1654
  /**
1654
1655
  * The functions directory of the store app, `src/store/convex` of this
1655
1656
  * package. This file is `src/State.ts` in a checkout and a chunk in `lib/`
@@ -1694,7 +1695,7 @@ const pushStore = (options, deployment) => Effect.gen(function* () {
1694
1695
  * it never reads a key back.
1695
1696
  */
1696
1697
  const ensureSealed = (options, deployment, functions) => Effect.gen(function* () {
1697
- const { current, sealed } = yield* functions.call("state:sealing", {}).pipe(Effect.flatMap(valueOf), Effect.flatMap(decode(Sealing, "state:sealing")));
1698
+ const { current, sealed } = yield* functions.call("state:sealing", {}).pipe(Effect.flatMap(valueOf), Effect.flatMap(decode$1(Sealing, "state:sealing")));
1698
1699
  if (current === null) {
1699
1700
  const keyId = newKeyId();
1700
1701
  yield* withPushKey(options.api, deployment.name, PUSH_KEY_ACTIONS, (key) => writeVariables(deployment.deploymentUrl, key, [{
@@ -1704,7 +1705,7 @@ const ensureSealed = (options, deployment, functions) => Effect.gen(function* ()
1704
1705
  yield* functions.call("state:chooseKey", { keyId }).pipe(Effect.flatMap(valueOf));
1705
1706
  }
1706
1707
  if (sealed) return;
1707
- const { failed } = yield* functions.call("state:reseal", {}).pipe(Effect.flatMap(valueOf), Effect.flatMap(decode(Resealed, "state:reseal")));
1708
+ const { failed } = yield* functions.call("state:reseal", {}).pipe(Effect.flatMap(valueOf), Effect.flatMap(decode$1(Resealed, "state:reseal")));
1708
1709
  if (failed.length > 0) yield* Effect.logWarning(`Convex state: the store could not seal ${failed.join(", ")}. The next run tries again.`);
1709
1710
  });
1710
1711
  /**
@@ -1987,6 +1988,398 @@ const registerDevelopmentClient = (options) => Effect.gen(function* () {
1987
1988
  return callbackUrl;
1988
1989
  });
1989
1990
  //#endregion
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 };
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 };
1991
2384
 
1992
- //# sourceMappingURL=HubClient-Bs5_j18U.js.map
2385
+ //# sourceMappingURL=SharedHubClient-D-FJJUCZ.js.map