@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 +25 -1
- package/lib/{HubClient-Bs5_j18U.js → SharedHubClient-D-FJJUCZ.js} +403 -10
- package/lib/SharedHubClient-D-FJJUCZ.js.map +1 -0
- package/lib/cli.js +34 -17
- package/lib/cli.js.map +1 -1
- package/lib/index.d.ts +8 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -156
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/SharedHubClient.ts +340 -18
- package/src/cli.ts +63 -19
- package/src/store/component/runRecord.ts +15 -0
- package/src/store/component/state.ts +32 -9
- package/lib/HubClient-Bs5_j18U.js.map +0 -1
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`,
|
|
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
|
-
|
|
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=
|
|
2385
|
+
//# sourceMappingURL=SharedHubClient-D-FJJUCZ.js.map
|