@samebase/alchemy-convex 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,17 @@
1
1
  // Convex.SharedHubClient: one deployment of an app as a client of the central identity hub, for an
2
- // app that deploys in its own GitHub Actions run.
2
+ // app that deploys in its own GitHub Actions run, on any host.
3
3
  //
4
4
  // The run asks samebase.com for a new client with a GitHub OIDC token, so the stack needs no
5
- // Samebase key. Samebase checks the token, chooses the callback, and registers the client. The
6
- // answer holds the client's own registration access token (RFC 7591), and each later deploy reads
7
- // the client on the hub with that token (RFC 7592). The hub keeps only hashes, so the state is the
8
- // only record of the secret and the token.
5
+ // Samebase key. The token names the repository. Samebase reads `/samebase-verification.json` at the
6
+ // origin of the return address, which must name the same repository, and registers the client on
7
+ // that address. The answer holds the client's own registration access token (RFC 7591), and each
8
+ // later deploy reads the client on the hub with that token (RFC 7592). The hub keeps only hashes, so
9
+ // the state is the only record of the secret and the token.
9
10
  //
10
11
  // A push removes a variable that a deploy stops passing, so a failure here must not drop working
11
12
  // variables or stop the deploy. A renewal that fails keeps the saved values. Only a client that
12
- // cannot work asks for a new one: it is gone, or it has the callback of another deployment. If that
13
- // request fails, the run gives the off values, so the app has guest sign-in and not a broken button.
13
+ // cannot work asks for a new one: it is gone, or it has another return address. If that request
14
+ // fails, the run gives the off values, so the app has guest sign-in and not a broken button.
14
15
  // The off values are the `no-hub` variables of a checkout without a hub: Convex needs the variables,
15
16
  // and the app hides the button for this client ID.
16
17
  //
@@ -19,6 +20,7 @@ import { Resource } from "alchemy";
19
20
  import { isResolved } from "alchemy/Diff";
20
21
  import * as Provider from "alchemy/Provider";
21
22
  import * as Effect from "effect/Effect";
23
+ import * as Option from "effect/Option";
22
24
  import * as Redacted from "effect/Redacted";
23
25
  import * as Schema from "effect/Schema";
24
26
  import type { Providers } from "./Providers.ts";
@@ -29,8 +31,12 @@ const REQUEST_TIMEOUT_MS = 30_000;
29
31
  export interface SharedHubClientProps {
30
32
  /** The stable name of the app, set once in the stack. */
31
33
  readonly app: string;
32
- /** The Convex deployment name. It can come from a deployment that this run creates. */
33
- readonly deployment: string;
34
+ /**
35
+ * The return address of the hub's sign-ins, such as `${site.siteUrl}/auth/hub/callback`: an https
36
+ * URL whose origin serves `/samebase-verification.json` with the numeric GitHub id of this
37
+ * repository, `{ "repository": "123" }`. It can come from a resource that this run creates.
38
+ */
39
+ readonly redirectUri: string;
34
40
  readonly deploymentType: "production" | "preview";
35
41
  /** Recorded on the hub's client, such as the commit. The hub records "unknown" without it. */
36
42
  readonly softwareVersion?: string;
@@ -47,9 +53,8 @@ export interface SharedHubClientAttributes {
47
53
  /** Returned only at registration. It reads and deletes this client on the hub, and nothing else. */
48
54
  readonly registrationAccessToken: Redacted.Redacted<string>;
49
55
  readonly registrationClientUri: string;
50
- /** The deployment that the client serves. */
51
- readonly deployment: string;
52
56
  readonly deploymentType: "production" | "preview";
57
+ /** The return address that the client serves. Empty for the off values. */
53
58
  readonly redirectUri: string;
54
59
  }
55
60
 
@@ -82,13 +87,44 @@ const NewClient = Schema.Struct({
82
87
 
83
88
  /** The other answers of `POST /api/hub-clients`, by status. */
84
89
  const REFUSALS: Readonly<Record<number, string>> = {
90
+ 400: "Samebase refused the request: the return address must be https on a DNS name",
85
91
  401: "Samebase refused the GitHub OIDC token",
86
- 403: "Samebase does not allow this repository or deployment",
92
+ 403: "Samebase does not allow this repository or return address",
87
93
  404: "hub clients are off on Samebase",
88
94
  429: "Samebase limits the requests of this repository",
89
95
  502: "the identity hub is not available",
90
96
  };
91
97
 
98
+ /** The reasons of a 403 that a log line shows. Any other text of the answer is never logged. */
99
+ const NOT_ALLOWED = new Map([
100
+ ["run_not_allowed", "Samebase gives clients only to runs of main and of pull requests"],
101
+ ["repository_not_connected", "the repository is not connected to Samebase"],
102
+ ["owner_mismatch", "the repository's owner is not the owner connected to Samebase"],
103
+ ["host_unreachable", "Samebase could not reach the host of the return address"],
104
+ [
105
+ "verification_file_missing",
106
+ "the host of the return address does not serve /samebase-verification.json",
107
+ ],
108
+ ["verification_file_invalid", "/samebase-verification.json on the host holds no repository id"],
109
+ ["repository_mismatch", "/samebase-verification.json on the host names another repository"],
110
+ ["hub_refused", "the identity hub refused this repository"],
111
+ ]);
112
+
113
+ const Refusal = Schema.Struct({ reason: Schema.String });
114
+
115
+ /** The text of a refusal: a known reason of a 403, else the text of its status. */
116
+ const refusalOf = (status: number, body: unknown) => {
117
+ const reason =
118
+ status === 403
119
+ ? Option.getOrUndefined(Schema.decodeUnknownOption(Refusal)(body))?.reason
120
+ : undefined;
121
+ return (
122
+ (reason === undefined ? undefined : NOT_ALLOWED.get(reason)) ??
123
+ REFUSALS[status] ??
124
+ "Samebase refused the request"
125
+ );
126
+ };
127
+
92
128
  const send = (who: string, url: string, token: string, init: { method: string; body?: string }) =>
93
129
  Effect.tryPromise({
94
130
  try: async () => {
@@ -138,14 +174,14 @@ const requestNewClient = (props: SharedHubClientProps, requestUrl: string, reque
138
174
  method: "POST",
139
175
  body: JSON.stringify({
140
176
  app: props.app,
141
- deployment: props.deployment,
177
+ redirectUri: props.redirectUri,
142
178
  softwareVersion: props.softwareVersion ?? "unknown",
143
179
  }),
144
180
  },
145
181
  );
146
182
  if (status !== 201) {
147
183
  return yield* new SharedHubClientFailed({
148
- reason: `${REFUSALS[status] ?? "Samebase refused the request"} (HTTP ${status})`,
184
+ reason: `${refusalOf(status, body)} (HTTP ${status})`,
149
185
  });
150
186
  }
151
187
  const client = yield* decode(NewClient, "Samebase")(body);
@@ -156,7 +192,6 @@ const requestNewClient = (props: SharedHubClientProps, requestUrl: string, reque
156
192
  clientSecret: Redacted.make(client.client_secret),
157
193
  registrationAccessToken: Redacted.make(client.registration_access_token),
158
194
  registrationClientUri: client.registration_client_uri,
159
- deployment: props.deployment,
160
195
  deploymentType: props.deploymentType,
161
196
  redirectUri: client.redirect_uri,
162
197
  };
@@ -164,7 +199,7 @@ const requestNewClient = (props: SharedHubClientProps, requestUrl: string, reque
164
199
 
165
200
  /** A new client, or `saved`, or the off values. `saved` holds no off values. */
166
201
  const newClient = (props: SharedHubClientProps) => {
167
- const label = `Convex.SharedHubClient: ${props.app} on ${props.deployment} deploys without the hub`;
202
+ const label = `Convex.SharedHubClient: ${props.app} on ${props.redirectUri} deploys without the hub`;
168
203
  const off: SharedHubClientAttributes = {
169
204
  status: "off",
170
205
  issuer: "http://127.0.0.1:1",
@@ -172,7 +207,6 @@ const newClient = (props: SharedHubClientProps) => {
172
207
  clientSecret: Redacted.make("no-hub"),
173
208
  registrationAccessToken: Redacted.make(""),
174
209
  registrationClientUri: "",
175
- deployment: props.deployment,
176
210
  deploymentType: props.deploymentType,
177
211
  redirectUri: "",
178
212
  };
@@ -212,7 +246,7 @@ const renew = (saved: SharedHubClientAttributes) =>
212
246
  return undefined;
213
247
  case 403:
214
248
  yield* Effect.logWarning(
215
- `Convex.SharedHubClient: the hub suspended the client of ${saved.deployment}. It keeps its values, and nobody can sign in with it.`,
249
+ `Convex.SharedHubClient: the hub suspended the client of ${saved.redirectUri}. It keeps its values, and nobody can sign in with it.`,
216
250
  );
217
251
  return { ...saved, status: "suspended" as const };
218
252
  default:
@@ -223,7 +257,7 @@ const renew = (saved: SharedHubClientAttributes) =>
223
257
  }).pipe(
224
258
  Effect.catchTag("SharedHubClientFailed", (error) =>
225
259
  Effect.logWarning(
226
- `Convex.SharedHubClient: ${saved.deployment} keeps the saved client: ${error.reason}.`,
260
+ `Convex.SharedHubClient: ${saved.redirectUri} keeps the saved client: ${error.reason}.`,
227
261
  ).pipe(Effect.as(saved)),
228
262
  ),
229
263
  );
@@ -239,7 +273,7 @@ export const SharedHubClientProvider = () =>
239
273
 
240
274
  reconcile: Effect.fn(function* ({ news, output }) {
241
275
  const saved = output?.status === "off" ? undefined : output;
242
- const renewed = saved?.deployment === news.deployment ? yield* renew(saved) : undefined;
276
+ const renewed = saved?.redirectUri === news.redirectUri ? yield* renew(saved) : undefined;
243
277
  return renewed ?? (yield* newClient(news));
244
278
  }),
245
279
 
@@ -264,7 +298,7 @@ export const SharedHubClientProvider = () =>
264
298
  }).pipe(
265
299
  Effect.catchTag("SharedHubClientFailed", (error) =>
266
300
  Effect.logWarning(
267
- `Convex.SharedHubClient: the client of ${output.deployment} stays on the hub until its removal date: ${error.reason}.`,
301
+ `Convex.SharedHubClient: the client of ${output.redirectUri} stays on the hub until its removal date: ${error.reason}.`,
268
302
  ),
269
303
  ),
270
304
  ),
package/src/cli.ts CHANGED
@@ -27,14 +27,15 @@ const { positionals, values } = parseArgs({
27
27
  team: { type: "string" },
28
28
  app: { type: "string" },
29
29
  "callback-path": { type: "string" },
30
+ "env-prefix": { type: "string" },
30
31
  },
31
32
  });
32
- const { team, app, "callback-path": callbackPath } = values;
33
+ const { team, app, "callback-path": callbackPath, "env-prefix": envPrefix } = values;
33
34
  const USAGE = `Usage:
34
- alchemy-convex hub-client --team <team> --app <app> --callback-path <path>
35
+ alchemy-convex hub-client --team <team> --app <app> --callback-path <path> [--env-prefix HUB]
35
36
  alchemy-convex stack-comment [--team <team>]`;
36
37
 
37
- const hubClient = (hub: { team: string; app: string; callbackPath: string }) =>
38
+ const hubClient = (hub: { team: string; app: string; callbackPath: string; envPrefix?: string }) =>
38
39
  Effect.gen(function* () {
39
40
  const [command, ...prefix] = yield* installedCli(process.cwd());
40
41
  const callbackUrl = yield* registerDevelopmentClient({
@@ -67,7 +68,7 @@ const program: Effect.Effect<unknown, unknown> | undefined =
67
68
  team !== undefined &&
68
69
  app !== undefined &&
69
70
  callbackPath !== undefined
70
- ? hubClient({ team, app, callbackPath })
71
+ ? hubClient({ team, app, callbackPath, ...(envPrefix === undefined ? {} : { envPrefix }) })
71
72
  : undefined;
72
73
  if (program === undefined) {
73
74
  console.error(USAGE);