@samebase/alchemy-convex 0.14.1 → 0.16.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.
@@ -0,0 +1,260 @@
1
+ // Convex.HubClient: one deployment of an app as a client of the identity hub
2
+ // of its team.
3
+ //
4
+ // The hub is the production deployment of the project `samebase` of the team.
5
+ // It has the internal functions of `@samebase/identity-broker` in its module
6
+ // `identityBroker`. This resource finds the project by name and runs those
7
+ // functions with a short-lived key, so a stack that has the Convex credential
8
+ // of the team needs no other credential and no hub address. The hub answers
9
+ // with its issuer and the client.
10
+ //
11
+ // The hub keeps only the hash of the secret, so the state is the only record
12
+ // of it. A client belongs to one redirect URI: another URI is a new client. A
13
+ // change of `app` updates the stored props and does not move the client: the
14
+ // app is the identity of the users of the app, and it does not change. The
15
+ // team does not change either: the state of a stack is in its team.
16
+ import { Resource } from "alchemy";
17
+ import { isResolved } from "alchemy/Diff";
18
+ import * as Provider from "alchemy/Provider";
19
+ import * as Effect from "effect/Effect";
20
+ import * as Redacted from "effect/Redacted";
21
+ import * as Schema from "effect/Schema";
22
+ import { ManagementApi, type ManagementApiService } from "./ManagementApi.ts";
23
+ import { findProjectByName, prodDeploymentOf } from "./Project.ts";
24
+ import type { Providers } from "./Providers.ts";
25
+ import { withPushKey } from "./Push.ts";
26
+ import { type FunctionApi, makeFunctionApi, RUN_KEY_ACTIONS } from "./State.ts";
27
+
28
+ /** The name of the project of the hub in each team. */
29
+ export const HUB_PROJECT_NAME = "samebase";
30
+
31
+ export type HubDeploymentType = "production" | "development" | "preview";
32
+
33
+ export interface HubClientProps {
34
+ /** The Convex team, a slug or an id, whose project `samebase` holds the hub. */
35
+ readonly team: string | number;
36
+ /** The app on the hub, such as `scout`. The first client of an app creates it, as a first-party app. */
37
+ readonly app: string;
38
+ /** The deployment's callback URL, such as `<deployment site URL>/auth/hub/callback`. */
39
+ readonly redirectUri: string;
40
+ readonly deploymentType: HubDeploymentType;
41
+ /** The name the hub records for the client. */
42
+ readonly clientName: string;
43
+ /** Recorded on the hub's client, such as the commit. The hub records "unknown" without it. */
44
+ readonly softwareVersion?: string;
45
+ }
46
+
47
+ export interface HubClientAttributes {
48
+ /** The issuer of the hub, which the deployment names to sign in. */
49
+ readonly issuer: string;
50
+ readonly redirectUri: string;
51
+ readonly deploymentType: HubDeploymentType;
52
+ /** The name the client was registered with. */
53
+ readonly clientName: string;
54
+ readonly clientId: string;
55
+ /** Returned by the hub only at registration. */
56
+ readonly clientSecret: Redacted.Redacted<string>;
57
+ }
58
+
59
+ export type HubClient = Resource<
60
+ "IdentityHub.Client",
61
+ HubClientProps,
62
+ HubClientAttributes,
63
+ never,
64
+ Providers
65
+ >;
66
+ export const HubClient = Resource<HubClient>("IdentityHub.Client");
67
+
68
+ /** The hub of the team could not do the call. The reason is the Convex message: it holds no secret. */
69
+ export class HubClientFailed extends Schema.TaggedError<HubClientFailed>()("HubClientFailed", {
70
+ team: Schema.Union([Schema.String, Schema.Number]),
71
+ reason: Schema.String,
72
+ }) {
73
+ override get message() {
74
+ return `Convex.HubClient: the hub of team ${this.team}: ${this.reason}.`;
75
+ }
76
+ }
77
+
78
+ /** The reply of `identityBroker:registerClient`. */
79
+ const Registered = Schema.Struct({
80
+ issuer: Schema.NonEmptyString,
81
+ clientId: Schema.NonEmptyString,
82
+ clientSecret: Schema.NonEmptyString,
83
+ });
84
+
85
+ /** Runs `use` with the function API of the hub of `team`, with a key of this call only. */
86
+ const withHub = <A, E, R>(
87
+ api: ManagementApiService,
88
+ team: string | number,
89
+ use: (functions: FunctionApi) => Effect.Effect<A, E, R>,
90
+ ) =>
91
+ Effect.gen(function* () {
92
+ const teamId = yield* api.resolveTeamId(team);
93
+ const project = yield* findProjectByName(api, teamId, HUB_PROJECT_NAME);
94
+ if (project === undefined) {
95
+ return yield* new HubClientFailed({
96
+ team,
97
+ reason: `no project ${HUB_PROJECT_NAME}, which holds the hub`,
98
+ });
99
+ }
100
+ const deployment = yield* prodDeploymentOf(api, project.id);
101
+ if (deployment === undefined) {
102
+ return yield* new HubClientFailed({
103
+ team,
104
+ reason: `project ${HUB_PROJECT_NAME} has no production deployment`,
105
+ });
106
+ }
107
+ return yield* withPushKey(api, deployment.name, RUN_KEY_ACTIONS, (key) =>
108
+ use(makeFunctionApi(deployment.deploymentUrl, key)),
109
+ );
110
+ });
111
+
112
+ /** One internal function of the hub: its value, or the Convex message as an error. */
113
+ const runHub = (
114
+ functions: FunctionApi,
115
+ team: string | number,
116
+ name: string,
117
+ args: Record<string, string>,
118
+ ) =>
119
+ functions.call(`identityBroker:${name}`, args).pipe(
120
+ Effect.mapError((error) => new HubClientFailed({ team, reason: error.message })),
121
+ Effect.flatMap((reply) =>
122
+ reply.status === "success"
123
+ ? Effect.succeed(reply.value)
124
+ : Effect.fail(new HubClientFailed({ team, reason: reply.errorMessage.trim() })),
125
+ ),
126
+ );
127
+
128
+ /**
129
+ * Registers one client. An attempt that a lost reply repeats leaves one spare
130
+ * client on the hub, which signs nobody in.
131
+ */
132
+ export const registerHubClient = (api: ManagementApiService, props: HubClientProps) =>
133
+ withHub(api, props.team, (functions) =>
134
+ runHub(functions, props.team, "registerClient", {
135
+ appId: props.app,
136
+ redirectUri: props.redirectUri,
137
+ deploymentType: props.deploymentType,
138
+ clientName: props.clientName,
139
+ softwareVersion: props.softwareVersion ?? "unknown",
140
+ // Convex Auth's generic OAuth method posts the client credentials in the body.
141
+ tokenEndpointAuthMethod: "client_secret_post",
142
+ }),
143
+ ).pipe(
144
+ Effect.flatMap((value) =>
145
+ Schema.decodeUnknownEffect(Registered)(value).pipe(
146
+ Effect.mapError(
147
+ () => new HubClientFailed({ team: props.team, reason: "registerClient gave no client" }),
148
+ ),
149
+ ),
150
+ ),
151
+ Effect.map(({ issuer, clientId, clientSecret }) => ({
152
+ issuer,
153
+ clientId,
154
+ clientSecret: Redacted.make(clientSecret),
155
+ })),
156
+ );
157
+
158
+ const unregister = (api: ManagementApiService, team: string | number, clientId: string) =>
159
+ withHub(api, team, (functions) => runHub(functions, team, "deleteClient", { clientId }));
160
+
161
+ /** True when the client in state serves the redirect URI and deployment type of `props`. */
162
+ const registeredFor = (output: HubClientAttributes, props: HubClientProps) =>
163
+ output.redirectUri === props.redirectUri && output.deploymentType === props.deploymentType;
164
+
165
+ export const HubClientProvider = () =>
166
+ Provider.effect(
167
+ HubClient,
168
+ Effect.gen(function* () {
169
+ const api = yield* ManagementApi;
170
+ return {
171
+ diff: ({ news, olds, output }) =>
172
+ Effect.succeed(
173
+ // Props that are unresolved at plan time, such as a redirectUri from a deployment that
174
+ // this run creates, are compared in reconcile.
175
+ !isResolved(news) || output === undefined
176
+ ? undefined
177
+ : !registeredFor(output, news)
178
+ ? ({ action: "replace" } as const)
179
+ : olds.team === news.team && olds.app === news.app
180
+ ? ({ action: "noop" } as const)
181
+ : ({ action: "update" } as const),
182
+ ),
183
+
184
+ read: ({ output }) => Effect.succeed(output),
185
+
186
+ reconcile: Effect.fn(function* ({ news, output }) {
187
+ if (output !== undefined && registeredFor(output, news)) return output;
188
+ const client = yield* registerHubClient(api, news);
189
+ // An update whose props were unresolved at plan time registers in place of a
190
+ // replacement. The new client is returned whatever happens to the old one.
191
+ if (output !== undefined) {
192
+ yield* unregister(api, news.team, output.clientId).pipe(
193
+ Effect.catchTag("HubClientFailed", (error) =>
194
+ Effect.logWarning(
195
+ `Client ${output.clientId} stays on the hub: ${error.message} Delete it with \`npx convex run --prod identityBroker:deleteClient\`.`,
196
+ ),
197
+ ),
198
+ );
199
+ }
200
+ return {
201
+ ...client,
202
+ redirectUri: news.redirectUri,
203
+ deploymentType: news.deploymentType,
204
+ clientName: news.clientName,
205
+ };
206
+ }),
207
+
208
+ delete: ({ olds, output }) =>
209
+ unregister(api, olds.team, output.clientId).pipe(Effect.asVoid),
210
+ };
211
+ }),
212
+ );
213
+
214
+ export interface DevelopmentClientOptions {
215
+ readonly team: string | number;
216
+ readonly app: string;
217
+ /** The path of the hub's callback after the site URL of the deployment. */
218
+ readonly callbackPath: string;
219
+ /** Runs the Convex CLI on the deployment of this directory, and answers its output. */
220
+ readonly convex: (args: readonly string[], input?: string) => string;
221
+ }
222
+
223
+ /** The variables that a deployment reads to sign in through the hub. */
224
+ const HUB_VARIABLES = ["HUB_ISSUER", "HUB_CLIENT_ID", "HUB_CLIENT_SECRET"];
225
+
226
+ /**
227
+ * `alchemy-convex hub-client`: a stack deploys production and previews, and this covers the
228
+ * development deployment, which no stack deploys. A deployment with all three variables keeps
229
+ * them. So a deployment that holds placeholder values for a checkout without a hub is left alone.
230
+ * The secret reaches the Convex CLI on standard input, never in its arguments.
231
+ */
232
+ export const registerDevelopmentClient = (options: DevelopmentClientOptions) =>
233
+ Effect.gen(function* () {
234
+ const { convex } = options;
235
+ const names = convex(["env", "list", "--names-only"]).split("\n");
236
+ if (HUB_VARIABLES.every((name) => names.includes(name))) return undefined;
237
+ const siteUrl = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(
238
+ convex(["run", "--inline-query", "process.env.CONVEX_SITE_URL"]),
239
+ ).pipe(
240
+ Effect.mapError(
241
+ () => new HubClientFailed({ team: options.team, reason: "no CONVEX_SITE_URL to register" }),
242
+ ),
243
+ );
244
+ const callbackUrl = `${siteUrl.replace(/\/$/, "")}${options.callbackPath}`;
245
+ const client = yield* registerHubClient(yield* ManagementApi, {
246
+ team: options.team,
247
+ app: options.app,
248
+ redirectUri: callbackUrl,
249
+ deploymentType: "development",
250
+ clientName: "development",
251
+ softwareVersion: new URL(siteUrl).host,
252
+ });
253
+ // One request, so that an interrupted run cannot leave half a client. `--force` replaces the
254
+ // values of an earlier one.
255
+ convex(
256
+ ["env", "set", "--force"],
257
+ `HUB_ISSUER='${client.issuer}'\nHUB_CLIENT_ID='${client.clientId}'\nHUB_CLIENT_SECRET='${Redacted.value(client.clientSecret)}'\n`,
258
+ );
259
+ return callbackUrl;
260
+ });
package/src/Providers.ts CHANGED
@@ -4,6 +4,7 @@ import * as Provider from "alchemy/Provider";
4
4
  import * as Layer from "effect/Layer";
5
5
  import { type Credentials, fromConfig } from "./Credentials.ts";
6
6
  import { DeploymentProvider, DeploymentResource } from "./Deployment.ts";
7
+ import { HubClient, HubClientProvider } from "./HubClient.ts";
7
8
  import { ManagementApiLive } from "./ManagementApi.ts";
8
9
  import { Project, ProjectProvider } from "./Project.ts";
9
10
 
@@ -15,8 +16,8 @@ export class Providers extends Provider.ProviderCollection<Providers>()("Convex"
15
16
  * `CONVEX_DEPLOY_KEY`).
16
17
  */
17
18
  export const providers = (credentials: Layer.Layer<Credentials> = fromConfig()) =>
18
- Layer.effect(Providers, Provider.collection([Project, DeploymentResource])).pipe(
19
- Layer.provide(Layer.mergeAll(ProjectProvider(), DeploymentProvider())),
19
+ Layer.effect(Providers, Provider.collection([Project, DeploymentResource, HubClient])).pipe(
20
+ Layer.provide(Layer.mergeAll(ProjectProvider(), DeploymentProvider(), HubClientProvider())),
20
21
  Layer.provide(ManagementApiLive()),
21
22
  Layer.provide(credentials),
22
23
  Layer.orDie,
package/src/State.ts CHANGED
@@ -1,36 +1,33 @@
1
1
  // Alchemy state in Convex: `state: Convex.state({ team })` in `Alchemy.Stack`.
2
2
  //
3
- // One project for each Convex team, `samebase`, holds the state of
3
+ // One project for each Convex team, `alchemy-state-store`, holds the state of
4
4
  // every stack and stage of the team in its production deployment: the
5
5
  // tables `resources (stack, stage, fqn, value)` and `outputs (stack, stage,
6
6
  // value)` of the alchemyState component (src/store/component), served by the
7
- // functions of src/store/functions.ts. Two apps serve them: the store app of
8
- // src/store/convex (a seed), which this provider pushes, and the base app of
9
- // the team (a base), which mounts the component next to its own components.
10
- // The store lives outside every resource that a stack manages, so a stack
11
- // can create and destroy its own project. See docs/design/convex-state.md.
7
+ // functions of src/store/convex. The store lives
8
+ // outside every resource that a stack manages, so a stack can create and
9
+ // destroy its own project. See docs/design/convex-state.md.
12
10
  //
13
11
  // Bootstrap, on the first call of the run: the team token (CONVEX_ACCESS_TOKEN,
14
12
  // or the Convex CLI login) finds the project by name, or creates it; a deploy
15
13
  // key of its production deployment that can run internal queries and
16
14
  // mutations, and nothing else, is created for the run and deleted at its
17
- // end; the `state:version` query says the store version and the app that
18
- // serves it. Without one, or with an older seed, the installed Convex CLI
19
- // pushes src/store/convex to it, with a push key that is deleted after the
20
- // push. An older base fails the run: a push of the store app would erase the
21
- // base app. The raw state functions are internal; authenticated readers get
15
+ // end; the `state:version` query says whether the deployment has the store
16
+ // of this version. Without one, or with an older one, the installed Convex
17
+ // CLI pushes src/store/convex to it, with a push key that is deleted after
18
+ // the push. The raw state functions are internal; authenticated readers get
22
19
  // repository-scoped subscriptions with secret values removed. A key that stops working during a
23
20
  // long run (it expires after two hours) is replaced once per call.
24
21
  //
25
- // Two first runs of a team can both create a project named `samebase`.
22
+ // Two first runs of a team can both create a project named `alchemy-state-store`.
26
23
  // The run whose project is not the oldest deletes its own, before it writes
27
24
  // anything, and uses the oldest: both runs end on one store.
28
25
  //
29
26
  // Samebase reads `stacks`, `getOutput`, `readStack`, and `reader:repository`
30
- // by name. STORE_VERSION is the wire contract: it rises only when a function
31
- // that a released client calls changes or goes away, and a client works only
32
- // with a store of its own version. An older seed is pushed over; an older base
33
- // and a newer store fail the run.
27
+ // by name. The store and its readers are released and upgraded together:
28
+ // STORE_VERSION rises with each change of a function, and a client works only
29
+ // with a store of its own version. An older store is pushed over; a newer one
30
+ // fails the run.
34
31
  //
35
32
  // The stage output that `setOutput` stores is the stack's output plus the
36
33
  // run record of src/RunRecord.ts under the key `samebase.run`: the
@@ -83,7 +80,7 @@ import { type RunRecord, runRecord, withRunRecord } from "./RunRecord.ts";
83
80
  import { STORE_VERSION } from "./store/convex/version.ts";
84
81
 
85
82
  /** The name of the state project in each team. */
86
- export const STORE_PROJECT_NAME = "samebase";
83
+ export const STORE_PROJECT_NAME = "alchemy-state-store";
87
84
 
88
85
  /**
89
86
  * The actions of the key of one run: the internal queries and mutations of
@@ -244,39 +241,6 @@ const storeVersion = (api: FunctionApi) =>
244
241
  ),
245
242
  );
246
243
 
247
- /**
248
- * The app that serves the store. A store without `state:host` is a seed: it
249
- * was pushed before 0.14.0, and only the store app of this package served
250
- * those stores.
251
- */
252
- const storeHost = (api: FunctionApi) =>
253
- api
254
- .call("state:host", {})
255
- .pipe(
256
- Effect.flatMap((reply) =>
257
- reply.status === "error" && MISSING_FUNCTION.test(reply.errorMessage)
258
- ? Effect.succeed("seed" as const)
259
- : valueOf(reply).pipe(
260
- Effect.flatMap(decode(Schema.Literals(["seed", "base"]), "store host")),
261
- ),
262
- ),
263
- );
264
-
265
- /**
266
- * The base app of the team serves an older store. Convex.state() never
267
- * pushes its store app over a base, because the push replaces every function
268
- * of the deployment and so erases the base app.
269
- */
270
- export class OutdatedBase extends Schema.TaggedError<OutdatedBase>()("OutdatedBase", {
271
- team: Schema.Union([Schema.String, Schema.Number]),
272
- deployment: Schema.String,
273
- version: Schema.Number,
274
- }) {
275
- override get message() {
276
- return `The base of team ${this.team} (${this.deployment}) answers store version ${this.version}, and this package needs store version ${STORE_VERSION}. Convex.state does not push over a base. Upgrade the dependency @samebase/alchemy-convex of the base app to the version of this package, and push the base app.`;
277
- }
278
- }
279
-
280
244
  /**
281
245
  * The functions directory of the store app, `src/store/convex` of this
282
246
  * package. This file is `src/State.ts` in a checkout and a chunk in `lib/`
@@ -288,7 +252,7 @@ export const storeDirectory = () =>
288
252
  join(dirname(fileURLToPath(import.meta.url)), "..", "src", "store", "convex");
289
253
 
290
254
  export interface StoreOptions {
291
- /** Team slug or numeric team id of the `samebase` project, or undefined with a team access token, which names its team. */
255
+ /** Team slug or numeric team id of the `alchemy-state-store` project, or undefined with a team access token, which names its team. */
292
256
  readonly team: string | number | undefined;
293
257
  readonly api: ManagementApiService;
294
258
  readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
@@ -365,9 +329,9 @@ const createStoreProject = (api: ManagementApiService, teamId: number, team: str
365
329
  });
366
330
 
367
331
  /**
368
- * The StateService over the `samebase` project of the team: found or
369
- * created, its store app pushed when the store is missing or an older seed,
370
- * and read and written with the keys of this run.
332
+ * The StateService over the `alchemy-state-store` project of the team: found or
333
+ * created, its store pushed when it is missing or older, and read and
334
+ * written with the keys of this run.
371
335
  */
372
336
  export const makeConvexState = (options: StoreOptions) =>
373
337
  Effect.gen(function* () {
@@ -415,38 +379,22 @@ export const makeConvexState = (options: StoreOptions) =>
415
379
  ),
416
380
  };
417
381
 
418
- // The store app is pushed only over no store or an older seed. A base
419
- // serves the same functions, and a push would erase the base app.
420
- const needsPush = (found: number | undefined) =>
421
- found === undefined
422
- ? Effect.succeed(true)
423
- : found < STORE_VERSION
424
- ? storeHost(functions).pipe(Effect.map((host) => host === "seed"))
425
- : Effect.succeed(false);
426
382
  let version = yield* storeVersion(functions);
427
- if (yield* needsPush(version)) {
383
+ if (version === undefined || version < STORE_VERSION) {
428
384
  // Code without `state:version` is another app with the name of the store.
429
385
  if (version === undefined && deployment.lastDeployTime) {
430
386
  return yield* failure(
431
387
  `project ${STORE_PROJECT_NAME} (${project.id}) of team ${team} has code that is not the state store, and Convex.state does not push over it. Rename that project in the Convex dashboard.`,
432
388
  );
433
389
  }
434
- // Another run can push a newer store, or the base app, in the meantime:
435
- // read again right before the push. The push itself has no lock (see
436
- // the design note).
390
+ // Another run can push a newer store in the meantime: read again right
391
+ // before the push. The push itself has no lock (see the design note).
437
392
  version = yield* storeVersion(functions);
438
- if (yield* needsPush(version)) {
393
+ if (version === undefined || version < STORE_VERSION) {
439
394
  yield* pushStore(options, deployment);
440
395
  version = yield* storeVersion(functions);
441
396
  }
442
397
  }
443
- if (
444
- version !== undefined &&
445
- version < STORE_VERSION &&
446
- (yield* storeHost(functions)) === "base"
447
- ) {
448
- return yield* new OutdatedBase({ team, deployment: deployment.name, version });
449
- }
450
398
  if (version !== STORE_VERSION) {
451
399
  return yield* failure(
452
400
  `${deployment.name} answers store version ${version ?? "none"}, and this package needs store version ${STORE_VERSION}.`,
@@ -493,12 +441,11 @@ export const makeConvexState = (options: StoreOptions) =>
493
441
  });
494
442
 
495
443
  /**
496
- * Alchemy state in the `samebase` project of `team`, with the team token
444
+ * Alchemy state in the `alchemy-state-store` project of `team`, with the team token
497
445
  * in CONVEX_ACCESS_TOKEN or the Convex CLI login. A team access token names
498
446
  * its team, so `team` is optional with one; the Convex CLI login needs the
499
447
  * slug or the id. The first use of the run finds or creates the project and
500
- * pushes the store app when the store is missing or an older seed. An older
501
- * base fails the run with {@link OutdatedBase}. `alchemy plan` on another
448
+ * pushes the store when it is missing or older. `alchemy plan` on another
502
449
  * stack never touches Convex.
503
450
  */
504
451
  export const state = (
package/src/cli.ts ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ // The command of the package:
3
+ //
4
+ // alchemy-convex hub-client --team <team> --app <app> --callback-path <path>
5
+ //
6
+ // It runs in the directory of a Convex app, and gives the development deployment of that directory
7
+ // a client of the identity hub of the team: see registerDevelopmentClient. It uses
8
+ // CONVEX_ACCESS_TOKEN or the Convex CLI login.
9
+ import { execFileSync } from "node:child_process";
10
+ import { parseArgs } from "node:util";
11
+ import * as Effect from "effect/Effect";
12
+ import * as Layer from "effect/Layer";
13
+ import { fromConfig } from "./Credentials.ts";
14
+ import { registerDevelopmentClient } from "./HubClient.ts";
15
+ import { ManagementApiLive } from "./ManagementApi.ts";
16
+ import { installedCli } from "./Push.ts";
17
+
18
+ const { positionals, values } = parseArgs({
19
+ allowPositionals: true,
20
+ options: {
21
+ team: { type: "string" },
22
+ app: { type: "string" },
23
+ "callback-path": { type: "string" },
24
+ },
25
+ });
26
+ const { team, app, "callback-path": callbackPath } = values;
27
+ if (
28
+ positionals.join(" ") !== "hub-client" ||
29
+ team === undefined ||
30
+ app === undefined ||
31
+ callbackPath === undefined
32
+ ) {
33
+ console.error(
34
+ "Usage: alchemy-convex hub-client --team <team> --app <app> --callback-path <path>",
35
+ );
36
+ process.exit(1);
37
+ }
38
+
39
+ const program = Effect.gen(function* () {
40
+ const [command, ...prefix] = yield* installedCli(process.cwd());
41
+ const callbackUrl = yield* registerDevelopmentClient({
42
+ team,
43
+ app,
44
+ callbackPath,
45
+ convex: (args, input) =>
46
+ execFileSync(command, [...prefix, ...args], {
47
+ encoding: "utf8",
48
+ input,
49
+ stdio: ["pipe", "pipe", "inherit"],
50
+ }).trim(),
51
+ });
52
+ console.log(
53
+ callbackUrl === undefined
54
+ ? "Hub client already registered."
55
+ : `Hub client registered for ${callbackUrl}.`,
56
+ );
57
+ }).pipe(Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))));
58
+
59
+ await Effect.runPromise(program).catch((error: unknown) => {
60
+ console.error(error instanceof Error ? error.message : String(error));
61
+ process.exit(1);
62
+ });
package/src/index.ts CHANGED
@@ -18,6 +18,14 @@ export {
18
18
  type DeploymentProps,
19
19
  ProductionDeploymentNotFound,
20
20
  } from "./Deployment.ts";
21
+ export {
22
+ HUB_PROJECT_NAME,
23
+ HubClient,
24
+ type HubClientAttributes,
25
+ HubClientFailed,
26
+ type HubClientProps,
27
+ type HubDeploymentType,
28
+ } from "./HubClient.ts";
21
29
  export { ConvexApiError, ManagementApi } from "./ManagementApi.ts";
22
30
  export {
23
31
  AmbiguousProject,
@@ -38,7 +46,7 @@ export {
38
46
  runRecord,
39
47
  withRunRecord,
40
48
  } from "./RunRecord.ts";
41
- export { OutdatedBase, state, STORE_PROJECT_NAME } from "./State.ts";
49
+ export { state, STORE_PROJECT_NAME } from "./State.ts";
42
50
  export {
43
51
  CodePushFailed,
44
52
  ConvexAppNotFound,
@@ -3,7 +3,7 @@
3
3
  // `ctx.auth`.
4
4
  import { v, type Infer } from "convex/values";
5
5
  import { query } from "./_generated/server.js";
6
- import { runRecord, validate } from "./runRecord.js";
6
+ import { runRecord, validate } from "./runRecord.ts";
7
7
 
8
8
  const stage = v.object({
9
9
  name: v.string(),
@@ -1,6 +1,6 @@
1
1
  import { defineSchema, defineTable } from "convex/server";
2
2
  import { v } from "convex/values";
3
- import { runRecord } from "./runRecord.js";
3
+ import { runRecord } from "./runRecord.ts";
4
4
 
5
5
  // `value` is the JSON text of Alchemy's encodeState(...): the same text that
6
6
  // Alchemy's local state writes to a file. A Convex object would reject keys
@@ -8,7 +8,7 @@
8
8
  import { v, type Infer } from "convex/values";
9
9
  import type { MutationCtx, QueryCtx } from "./_generated/server.js";
10
10
  import { mutation, query } from "./_generated/server.js";
11
- import { runRecord } from "./runRecord.js";
11
+ import { runRecord } from "./runRecord.ts";
12
12
 
13
13
  const stage = { stack: v.string(), stage: v.string() };
14
14
  const resource = { ...stage, fqn: v.string() };
@@ -1,4 +1,14 @@
1
- import { authConfig } from "../authConfig.js";
1
+ import type { AuthConfig } from "convex/server";
2
2
  import { env } from "./_generated/server.js";
3
3
 
4
- export default authConfig(env.CONVEX_CLOUD_URL);
4
+ export default {
5
+ providers: [
6
+ {
7
+ type: "customJwt",
8
+ issuer: env.CONVEX_CLOUD_URL,
9
+ applicationID: `${env.CONVEX_CLOUD_URL}/alchemy-reader`,
10
+ jwks: `${env.CONVEX_CLOUD_URL}/.well-known/jwks.json`,
11
+ algorithm: "ES256",
12
+ },
13
+ ],
14
+ } satisfies AuthConfig;
@@ -1,5 +1,5 @@
1
1
  import { defineApp } from "convex/server";
2
- import alchemyState from "../component/convex.config.js";
2
+ import alchemyState from "../component/convex.config.ts";
3
3
 
4
4
  const app = defineApp();
5
5
  app.use(alchemyState);
@@ -1,5 +1,51 @@
1
- // The authenticated, redacted reads of the store app, from src/store/functions.ts.
2
- import { readerFunctions } from "../functions.js";
1
+ // Authenticated reads stay in the app: `authorize` signs and `repository`
2
+ // verifies with `ctx.auth`, which a component does not have. The rows and the
3
+ // redaction are in the alchemyState component.
4
+ import { v } from "convex/values";
5
+ import { repositoryReply } from "../component/reader.ts";
3
6
  import { components } from "./_generated/api.js";
7
+ import { env, internalMutation, query } from "./_generated/server.js";
4
8
 
5
- export const { authorize, repository } = readerFunctions(components.alchemyState);
9
+ const repositoryName = /^[^/]+\/[^/]+$/;
10
+
11
+ /** Only a deployment credential can exchange an approved repository for a read token. */
12
+ export const authorize = internalMutation({
13
+ args: { repository: v.string(), subject: v.string() },
14
+ returns: v.object({ token: v.string() }),
15
+ handler: async (ctx, args) => {
16
+ if (!repositoryName.test(args.repository) || args.subject === "") {
17
+ throw new Error("A repository and subject are required.");
18
+ }
19
+ return {
20
+ token: await ctx.auth.signJwt({
21
+ expiresInSeconds: 300,
22
+ claims: {
23
+ sub: args.subject,
24
+ aud: `${env.CONVEX_CLOUD_URL}/alchemy-reader`,
25
+ repository: args.repository,
26
+ scope: "alchemy:read",
27
+ },
28
+ }),
29
+ };
30
+ },
31
+ });
32
+
33
+ /** A native subscription reads only the repository named in its verified capability. */
34
+ export const repository = query({
35
+ args: {},
36
+ returns: repositoryReply,
37
+ handler: async (ctx) => {
38
+ const identity = await ctx.auth.getUserIdentity();
39
+ if (
40
+ identity === null ||
41
+ identity.scope !== "alchemy:read" ||
42
+ typeof identity.repository !== "string" ||
43
+ !repositoryName.test(identity.repository)
44
+ ) {
45
+ throw new Error("Alchemy repository read access is required.");
46
+ }
47
+ return await ctx.runQuery(components.alchemyState.reader.repository, {
48
+ repository: identity.repository,
49
+ });
50
+ },
51
+ });