@samebase/alchemy-convex 0.14.0 → 0.15.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 +18 -0
- package/lib/HubClient-C_nvjlvW.js +1938 -0
- package/lib/HubClient-C_nvjlvW.js.map +1 -0
- package/lib/cli.d.ts +1 -0
- package/lib/cli.js +46 -0
- package/lib/cli.js.map +1 -0
- package/lib/index.d.ts +38 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +7 -1806
- package/lib/index.js.map +1 -1
- package/package.json +4 -1
- package/src/HubClient.ts +258 -0
- package/src/Providers.ts +3 -2
- package/src/cli.ts +62 -0
- package/src/index.ts +7 -0
- package/src/store/functions.ts +8 -6
package/src/HubClient.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Convex.HubClient: one deployment of an app as a client of the identity hub
|
|
2
|
+
// of its team.
|
|
3
|
+
//
|
|
4
|
+
// The hub is an app in the base of the team, the production deployment of the
|
|
5
|
+
// project `samebase`, the project that holds the state of the team. It has
|
|
6
|
+
// the internal functions of `@samebase/identity-broker` in its module
|
|
7
|
+
// `identityBroker`. This resource finds the base by name and runs those
|
|
8
|
+
// functions with a short-lived key, so a stack that has the Convex credential
|
|
9
|
+
// of the team needs no other credential and no hub address. The hub answers
|
|
10
|
+
// with its issuer and the client.
|
|
11
|
+
//
|
|
12
|
+
// The hub keeps only the hash of the secret, so the state is the only record
|
|
13
|
+
// of it. A client belongs to one redirect URI: another URI is a new client. A
|
|
14
|
+
// change of `app` updates the stored props and does not move the client: the
|
|
15
|
+
// app is the identity of the users of the app, and it does not change. The
|
|
16
|
+
// team does not change either: the state of a stack is in the base of its team.
|
|
17
|
+
import { Resource } from "alchemy";
|
|
18
|
+
import { isResolved } from "alchemy/Diff";
|
|
19
|
+
import * as Provider from "alchemy/Provider";
|
|
20
|
+
import * as Effect from "effect/Effect";
|
|
21
|
+
import * as Redacted from "effect/Redacted";
|
|
22
|
+
import * as Schema from "effect/Schema";
|
|
23
|
+
import { ManagementApi, type ManagementApiService } from "./ManagementApi.ts";
|
|
24
|
+
import { findProjectByName, prodDeploymentOf } from "./Project.ts";
|
|
25
|
+
import type { Providers } from "./Providers.ts";
|
|
26
|
+
import { withPushKey } from "./Push.ts";
|
|
27
|
+
import { type FunctionApi, makeFunctionApi, RUN_KEY_ACTIONS, STORE_PROJECT_NAME } from "./State.ts";
|
|
28
|
+
|
|
29
|
+
export type HubDeploymentType = "production" | "development" | "preview";
|
|
30
|
+
|
|
31
|
+
export interface HubClientProps {
|
|
32
|
+
/** The Convex team, a slug or an id, whose project `samebase` holds the hub. */
|
|
33
|
+
readonly team: string | number;
|
|
34
|
+
/** The app on the hub, such as `scout`. The first client of an app creates it, as a first-party app. */
|
|
35
|
+
readonly app: string;
|
|
36
|
+
/** The deployment's callback URL, such as `<deployment site URL>/auth/hub/callback`. */
|
|
37
|
+
readonly redirectUri: string;
|
|
38
|
+
readonly deploymentType: HubDeploymentType;
|
|
39
|
+
/** The name the hub records for the client. */
|
|
40
|
+
readonly clientName: string;
|
|
41
|
+
/** Recorded on the hub's client, such as the commit. The hub records "unknown" without it. */
|
|
42
|
+
readonly softwareVersion?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface HubClientAttributes {
|
|
46
|
+
/** The issuer of the hub, which the deployment names to sign in. */
|
|
47
|
+
readonly issuer: string;
|
|
48
|
+
readonly redirectUri: string;
|
|
49
|
+
readonly deploymentType: HubDeploymentType;
|
|
50
|
+
/** The name the client was registered with. */
|
|
51
|
+
readonly clientName: string;
|
|
52
|
+
readonly clientId: string;
|
|
53
|
+
/** Returned by the hub only at registration. */
|
|
54
|
+
readonly clientSecret: Redacted.Redacted<string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type HubClient = Resource<
|
|
58
|
+
"IdentityHub.Client",
|
|
59
|
+
HubClientProps,
|
|
60
|
+
HubClientAttributes,
|
|
61
|
+
never,
|
|
62
|
+
Providers
|
|
63
|
+
>;
|
|
64
|
+
export const HubClient = Resource<HubClient>("IdentityHub.Client");
|
|
65
|
+
|
|
66
|
+
/** The base of the team could not do the call. The reason is the Convex message: it holds no secret. */
|
|
67
|
+
export class HubClientFailed extends Schema.TaggedError<HubClientFailed>()("HubClientFailed", {
|
|
68
|
+
team: Schema.Union([Schema.String, Schema.Number]),
|
|
69
|
+
reason: Schema.String,
|
|
70
|
+
}) {
|
|
71
|
+
override get message() {
|
|
72
|
+
return `Convex.HubClient: the base of team ${this.team}: ${this.reason}.`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The reply of `identityBroker:registerClient`. */
|
|
77
|
+
const Registered = Schema.Struct({
|
|
78
|
+
issuer: Schema.NonEmptyString,
|
|
79
|
+
clientId: Schema.NonEmptyString,
|
|
80
|
+
clientSecret: Schema.NonEmptyString,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** Runs `use` with the function API of the base of `team`, with a key of this call only. */
|
|
84
|
+
const withBase = <A, E, R>(
|
|
85
|
+
api: ManagementApiService,
|
|
86
|
+
team: string | number,
|
|
87
|
+
use: (functions: FunctionApi) => Effect.Effect<A, E, R>,
|
|
88
|
+
) =>
|
|
89
|
+
Effect.gen(function* () {
|
|
90
|
+
const teamId = yield* api.resolveTeamId(team);
|
|
91
|
+
const project = yield* findProjectByName(api, teamId, STORE_PROJECT_NAME);
|
|
92
|
+
if (project === undefined) {
|
|
93
|
+
return yield* new HubClientFailed({
|
|
94
|
+
team,
|
|
95
|
+
reason: `no project ${STORE_PROJECT_NAME}, which holds the hub`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const deployment = yield* prodDeploymentOf(api, project.id);
|
|
99
|
+
if (deployment === undefined) {
|
|
100
|
+
return yield* new HubClientFailed({
|
|
101
|
+
team,
|
|
102
|
+
reason: `project ${STORE_PROJECT_NAME} has no production deployment`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return yield* withPushKey(api, deployment.name, RUN_KEY_ACTIONS, (key) =>
|
|
106
|
+
use(makeFunctionApi(deployment.deploymentUrl, key)),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** One internal function of the hub: its value, or the Convex message as an error. */
|
|
111
|
+
const runHub = (
|
|
112
|
+
functions: FunctionApi,
|
|
113
|
+
team: string | number,
|
|
114
|
+
name: string,
|
|
115
|
+
args: Record<string, string>,
|
|
116
|
+
) =>
|
|
117
|
+
functions.call(`identityBroker:${name}`, args).pipe(
|
|
118
|
+
Effect.mapError((error) => new HubClientFailed({ team, reason: error.message })),
|
|
119
|
+
Effect.flatMap((reply) =>
|
|
120
|
+
reply.status === "success"
|
|
121
|
+
? Effect.succeed(reply.value)
|
|
122
|
+
: Effect.fail(new HubClientFailed({ team, reason: reply.errorMessage.trim() })),
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Registers one client. An attempt that a lost reply repeats leaves one spare
|
|
128
|
+
* client on the hub, which signs nobody in.
|
|
129
|
+
*/
|
|
130
|
+
export const registerHubClient = (api: ManagementApiService, props: HubClientProps) =>
|
|
131
|
+
withBase(api, props.team, (functions) =>
|
|
132
|
+
runHub(functions, props.team, "registerClient", {
|
|
133
|
+
appId: props.app,
|
|
134
|
+
redirectUri: props.redirectUri,
|
|
135
|
+
deploymentType: props.deploymentType,
|
|
136
|
+
clientName: props.clientName,
|
|
137
|
+
softwareVersion: props.softwareVersion ?? "unknown",
|
|
138
|
+
// Convex Auth's generic OAuth method posts the client credentials in the body.
|
|
139
|
+
tokenEndpointAuthMethod: "client_secret_post",
|
|
140
|
+
}),
|
|
141
|
+
).pipe(
|
|
142
|
+
Effect.flatMap((value) =>
|
|
143
|
+
Schema.decodeUnknownEffect(Registered)(value).pipe(
|
|
144
|
+
Effect.mapError(
|
|
145
|
+
() => new HubClientFailed({ team: props.team, reason: "registerClient gave no client" }),
|
|
146
|
+
),
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
Effect.map(({ issuer, clientId, clientSecret }) => ({
|
|
150
|
+
issuer,
|
|
151
|
+
clientId,
|
|
152
|
+
clientSecret: Redacted.make(clientSecret),
|
|
153
|
+
})),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const unregister = (api: ManagementApiService, team: string | number, clientId: string) =>
|
|
157
|
+
withBase(api, team, (functions) => runHub(functions, team, "deleteClient", { clientId }));
|
|
158
|
+
|
|
159
|
+
/** True when the client in state serves the redirect URI and deployment type of `props`. */
|
|
160
|
+
const registeredFor = (output: HubClientAttributes, props: HubClientProps) =>
|
|
161
|
+
output.redirectUri === props.redirectUri && output.deploymentType === props.deploymentType;
|
|
162
|
+
|
|
163
|
+
export const HubClientProvider = () =>
|
|
164
|
+
Provider.effect(
|
|
165
|
+
HubClient,
|
|
166
|
+
Effect.gen(function* () {
|
|
167
|
+
const api = yield* ManagementApi;
|
|
168
|
+
return {
|
|
169
|
+
diff: ({ news, olds, output }) =>
|
|
170
|
+
Effect.succeed(
|
|
171
|
+
// Props that are unresolved at plan time, such as a redirectUri from a deployment that
|
|
172
|
+
// this run creates, are compared in reconcile.
|
|
173
|
+
!isResolved(news) || output === undefined
|
|
174
|
+
? undefined
|
|
175
|
+
: !registeredFor(output, news)
|
|
176
|
+
? ({ action: "replace" } as const)
|
|
177
|
+
: olds.team === news.team && olds.app === news.app
|
|
178
|
+
? ({ action: "noop" } as const)
|
|
179
|
+
: ({ action: "update" } as const),
|
|
180
|
+
),
|
|
181
|
+
|
|
182
|
+
read: ({ output }) => Effect.succeed(output),
|
|
183
|
+
|
|
184
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
185
|
+
if (output !== undefined && registeredFor(output, news)) return output;
|
|
186
|
+
const client = yield* registerHubClient(api, news);
|
|
187
|
+
// An update whose props were unresolved at plan time registers in place of a
|
|
188
|
+
// replacement. The new client is returned whatever happens to the old one.
|
|
189
|
+
if (output !== undefined) {
|
|
190
|
+
yield* unregister(api, news.team, output.clientId).pipe(
|
|
191
|
+
Effect.catchTag("HubClientFailed", (error) =>
|
|
192
|
+
Effect.logWarning(
|
|
193
|
+
`Client ${output.clientId} stays on the hub: ${error.message} Delete it with \`npx convex run --prod identityBroker:deleteClient\`.`,
|
|
194
|
+
),
|
|
195
|
+
),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
...client,
|
|
200
|
+
redirectUri: news.redirectUri,
|
|
201
|
+
deploymentType: news.deploymentType,
|
|
202
|
+
clientName: news.clientName,
|
|
203
|
+
};
|
|
204
|
+
}),
|
|
205
|
+
|
|
206
|
+
delete: ({ olds, output }) =>
|
|
207
|
+
unregister(api, olds.team, output.clientId).pipe(Effect.asVoid),
|
|
208
|
+
};
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
export interface DevelopmentClientOptions {
|
|
213
|
+
readonly team: string | number;
|
|
214
|
+
readonly app: string;
|
|
215
|
+
/** The path of the hub's callback after the site URL of the deployment. */
|
|
216
|
+
readonly callbackPath: string;
|
|
217
|
+
/** Runs the Convex CLI on the deployment of this directory, and answers its output. */
|
|
218
|
+
readonly convex: (args: readonly string[], input?: string) => string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The variables that a deployment reads to sign in through the hub. */
|
|
222
|
+
const HUB_VARIABLES = ["HUB_ISSUER", "HUB_CLIENT_ID", "HUB_CLIENT_SECRET"];
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* `alchemy-convex hub-client`: a stack deploys production and previews, and this covers the
|
|
226
|
+
* development deployment, which no stack deploys. A deployment with all three variables keeps
|
|
227
|
+
* them. So a deployment that holds placeholder values for a checkout without a hub is left alone.
|
|
228
|
+
* The secret reaches the Convex CLI on standard input, never in its arguments.
|
|
229
|
+
*/
|
|
230
|
+
export const registerDevelopmentClient = (options: DevelopmentClientOptions) =>
|
|
231
|
+
Effect.gen(function* () {
|
|
232
|
+
const { convex } = options;
|
|
233
|
+
const names = convex(["env", "list", "--names-only"]).split("\n");
|
|
234
|
+
if (HUB_VARIABLES.every((name) => names.includes(name))) return undefined;
|
|
235
|
+
const siteUrl = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(
|
|
236
|
+
convex(["run", "--inline-query", "process.env.CONVEX_SITE_URL"]),
|
|
237
|
+
).pipe(
|
|
238
|
+
Effect.mapError(
|
|
239
|
+
() => new HubClientFailed({ team: options.team, reason: "no CONVEX_SITE_URL to register" }),
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
const callbackUrl = `${siteUrl.replace(/\/$/, "")}${options.callbackPath}`;
|
|
243
|
+
const client = yield* registerHubClient(yield* ManagementApi, {
|
|
244
|
+
team: options.team,
|
|
245
|
+
app: options.app,
|
|
246
|
+
redirectUri: callbackUrl,
|
|
247
|
+
deploymentType: "development",
|
|
248
|
+
clientName: "development",
|
|
249
|
+
softwareVersion: new URL(siteUrl).host,
|
|
250
|
+
});
|
|
251
|
+
// One request, so that an interrupted run cannot leave half a client. `--force` replaces the
|
|
252
|
+
// values of an earlier one.
|
|
253
|
+
convex(
|
|
254
|
+
["env", "set", "--force"],
|
|
255
|
+
`HUB_ISSUER='${client.issuer}'\nHUB_CLIENT_ID='${client.clientId}'\nHUB_CLIENT_SECRET='${Redacted.value(client.clientSecret)}'\n`,
|
|
256
|
+
);
|
|
257
|
+
return callbackUrl;
|
|
258
|
+
});
|
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/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,13 @@ export {
|
|
|
18
18
|
type DeploymentProps,
|
|
19
19
|
ProductionDeploymentNotFound,
|
|
20
20
|
} from "./Deployment.ts";
|
|
21
|
+
export {
|
|
22
|
+
HubClient,
|
|
23
|
+
type HubClientAttributes,
|
|
24
|
+
HubClientFailed,
|
|
25
|
+
type HubClientProps,
|
|
26
|
+
type HubDeploymentType,
|
|
27
|
+
} from "./HubClient.ts";
|
|
21
28
|
export { ConvexApiError, ManagementApi } from "./ManagementApi.ts";
|
|
22
29
|
export {
|
|
23
30
|
AmbiguousProject,
|
package/src/store/functions.ts
CHANGED
|
@@ -193,20 +193,22 @@ export const readerFunctions = (alchemyState: ComponentApi) => ({
|
|
|
193
193
|
returns: repositoryReply,
|
|
194
194
|
handler: async (ctx) => {
|
|
195
195
|
const identity = await ctx.auth.getUserIdentity();
|
|
196
|
+
// Custom claims are bracket reads: a base app compiles this file under its own tsconfig,
|
|
197
|
+
// which can set noPropertyAccessFromIndexSignature.
|
|
198
|
+
const scope = identity?.["scope"];
|
|
199
|
+
const repository = identity?.["repository"];
|
|
196
200
|
// Only a token of the deployment's own issuer reads, the one that `authorize` signs: a base
|
|
197
201
|
// can accept other providers too, and a token of one of those with the same claims does not.
|
|
198
202
|
if (
|
|
199
203
|
identity === null ||
|
|
200
204
|
identity.issuer !== env.CONVEX_CLOUD_URL ||
|
|
201
|
-
|
|
202
|
-
typeof
|
|
203
|
-
!repositoryName.test(
|
|
205
|
+
scope !== "alchemy:read" ||
|
|
206
|
+
typeof repository !== "string" ||
|
|
207
|
+
!repositoryName.test(repository)
|
|
204
208
|
) {
|
|
205
209
|
throw new Error("Alchemy repository read access is required.");
|
|
206
210
|
}
|
|
207
|
-
return await ctx.runQuery(alchemyState.reader.repository, {
|
|
208
|
-
repository: identity.repository,
|
|
209
|
-
});
|
|
211
|
+
return await ctx.runQuery(alchemyState.reader.repository, { repository });
|
|
210
212
|
},
|
|
211
213
|
}),
|
|
212
214
|
});
|