@samebase/alchemy-convex 0.19.0 → 0.20.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 +20 -3
- package/lib/{HubClient-C0XxgkK3.js → HubClient-Dzw8uZNy.js} +18 -6
- package/lib/HubClient-Dzw8uZNy.js.map +1 -0
- package/lib/cli.js +13 -1
- package/lib/cli.js.map +1 -1
- package/lib/index.d.ts +38 -2
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +131 -4
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/ManagementApi.ts +22 -1
- package/src/Providers.ts +13 -2
- package/src/Push.ts +19 -11
- package/src/SharedHubClient.ts +271 -0
- package/src/StackComment.ts +8 -0
- package/src/index.ts +5 -0
- package/lib/HubClient-C0XxgkK3.js.map +0 -1
|
@@ -0,0 +1,271 @@
|
|
|
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.
|
|
3
|
+
//
|
|
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.
|
|
9
|
+
//
|
|
10
|
+
// A push removes a variable that a deploy stops passing, so a failure here must not drop working
|
|
11
|
+
// 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.
|
|
14
|
+
// The off values are the `no-hub` variables of a checkout without a hub: Convex needs the variables,
|
|
15
|
+
// and the app hides the button for this client ID.
|
|
16
|
+
//
|
|
17
|
+
// No text in this file quotes an answer or a fetch error, so no log line can hold a secret.
|
|
18
|
+
import { Resource } from "alchemy";
|
|
19
|
+
import { isResolved } from "alchemy/Diff";
|
|
20
|
+
import * as Provider from "alchemy/Provider";
|
|
21
|
+
import * as Effect from "effect/Effect";
|
|
22
|
+
import * as Redacted from "effect/Redacted";
|
|
23
|
+
import * as Schema from "effect/Schema";
|
|
24
|
+
import type { Providers } from "./Providers.ts";
|
|
25
|
+
|
|
26
|
+
/** A hung service must not hold a deploy: the time limit of one request. */
|
|
27
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
28
|
+
|
|
29
|
+
export interface SharedHubClientProps {
|
|
30
|
+
/** The stable name of the app, set once in the stack. */
|
|
31
|
+
readonly app: string;
|
|
32
|
+
/** The Convex deployment name. It can come from a deployment that this run creates. */
|
|
33
|
+
readonly deployment: string;
|
|
34
|
+
readonly deploymentType: "production" | "preview";
|
|
35
|
+
/** Recorded on the hub's client, such as the commit. The hub records "unknown" without it. */
|
|
36
|
+
readonly softwareVersion?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SharedHubClientAttributes {
|
|
40
|
+
/** `off`: no client, and the values below are the off values. */
|
|
41
|
+
readonly status: "on" | "off" | "suspended";
|
|
42
|
+
/** The issuer of the hub, which the deployment names to sign in. */
|
|
43
|
+
readonly issuer: string;
|
|
44
|
+
readonly clientId: string;
|
|
45
|
+
/** Returned only at registration. */
|
|
46
|
+
readonly clientSecret: Redacted.Redacted<string>;
|
|
47
|
+
/** Returned only at registration. It reads and deletes this client on the hub, and nothing else. */
|
|
48
|
+
readonly registrationAccessToken: Redacted.Redacted<string>;
|
|
49
|
+
readonly registrationClientUri: string;
|
|
50
|
+
/** The deployment that the client serves. */
|
|
51
|
+
readonly deployment: string;
|
|
52
|
+
readonly deploymentType: "production" | "preview";
|
|
53
|
+
readonly redirectUri: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type SharedHubClient = Resource<
|
|
57
|
+
"IdentityHub.SharedClient",
|
|
58
|
+
SharedHubClientProps,
|
|
59
|
+
SharedHubClientAttributes,
|
|
60
|
+
never,
|
|
61
|
+
Providers
|
|
62
|
+
>;
|
|
63
|
+
export const SharedHubClient = Resource<SharedHubClient>("IdentityHub.SharedClient");
|
|
64
|
+
|
|
65
|
+
/** One request gave no usable answer. The reason holds no secret. */
|
|
66
|
+
class SharedHubClientFailed extends Schema.TaggedError<SharedHubClientFailed>()(
|
|
67
|
+
"SharedHubClientFailed",
|
|
68
|
+
{ reason: Schema.String },
|
|
69
|
+
) {}
|
|
70
|
+
|
|
71
|
+
const OidcToken = Schema.Struct({ value: Schema.NonEmptyString });
|
|
72
|
+
|
|
73
|
+
/** The 201 answer of `POST /api/hub-clients`. */
|
|
74
|
+
const NewClient = Schema.Struct({
|
|
75
|
+
issuer: Schema.NonEmptyString,
|
|
76
|
+
client_id: Schema.NonEmptyString,
|
|
77
|
+
client_secret: Schema.NonEmptyString,
|
|
78
|
+
registration_access_token: Schema.NonEmptyString,
|
|
79
|
+
registration_client_uri: Schema.NonEmptyString,
|
|
80
|
+
redirect_uri: Schema.NonEmptyString,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** The other answers of `POST /api/hub-clients`, by status. */
|
|
84
|
+
const REFUSALS: Readonly<Record<number, string>> = {
|
|
85
|
+
401: "Samebase refused the GitHub OIDC token",
|
|
86
|
+
403: "Samebase does not allow this repository or deployment",
|
|
87
|
+
404: "hub clients are off on Samebase",
|
|
88
|
+
429: "Samebase limits the requests of this repository",
|
|
89
|
+
502: "the identity hub is not available",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const send = (who: string, url: string, token: string, init: { method: string; body?: string }) =>
|
|
93
|
+
Effect.tryPromise({
|
|
94
|
+
try: async () => {
|
|
95
|
+
const response = await fetch(url, {
|
|
96
|
+
...init,
|
|
97
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
98
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
99
|
+
});
|
|
100
|
+
const body: unknown = await response.json().catch(() => undefined);
|
|
101
|
+
return { status: response.status, body };
|
|
102
|
+
},
|
|
103
|
+
catch: () => new SharedHubClientFailed({ reason: `${who} gave no answer` }),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const decode =
|
|
107
|
+
<S extends Schema.Codec<unknown, unknown>>(schema: S, who: string) =>
|
|
108
|
+
(value: unknown) =>
|
|
109
|
+
Schema.decodeUnknownEffect(schema)(value).pipe(
|
|
110
|
+
Effect.mapError(() => new SharedHubClientFailed({ reason: `${who} gave a bad answer` })),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
/** The OIDC token of this run, read right before the call, because it lives for minutes only. */
|
|
114
|
+
const readOidcToken = (requestUrl: string, requestToken: string) =>
|
|
115
|
+
Effect.gen(function* () {
|
|
116
|
+
const { body, status } = yield* send(
|
|
117
|
+
"GitHub",
|
|
118
|
+
`${requestUrl}&audience=samebase`,
|
|
119
|
+
requestToken,
|
|
120
|
+
{ method: "GET" },
|
|
121
|
+
);
|
|
122
|
+
if (status !== 200) {
|
|
123
|
+
return yield* new SharedHubClientFailed({
|
|
124
|
+
reason: `GitHub gave no OIDC token (HTTP ${status})`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return (yield* decode(OidcToken, "GitHub")(body)).value;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const requestNewClient = (props: SharedHubClientProps, requestUrl: string, requestToken: string) =>
|
|
131
|
+
Effect.gen(function* () {
|
|
132
|
+
const token = yield* readOidcToken(requestUrl, requestToken);
|
|
133
|
+
const { body, status } = yield* send(
|
|
134
|
+
"Samebase",
|
|
135
|
+
`${process.env["SAMEBASE_URL"] ?? "https://samebase.com"}/api/hub-clients`,
|
|
136
|
+
token,
|
|
137
|
+
{
|
|
138
|
+
method: "POST",
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
app: props.app,
|
|
141
|
+
deployment: props.deployment,
|
|
142
|
+
softwareVersion: props.softwareVersion ?? "unknown",
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
if (status !== 201) {
|
|
147
|
+
return yield* new SharedHubClientFailed({
|
|
148
|
+
reason: `${REFUSALS[status] ?? "Samebase refused the request"} (HTTP ${status})`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const client = yield* decode(NewClient, "Samebase")(body);
|
|
152
|
+
return {
|
|
153
|
+
status: "on" as const,
|
|
154
|
+
issuer: client.issuer,
|
|
155
|
+
clientId: client.client_id,
|
|
156
|
+
clientSecret: Redacted.make(client.client_secret),
|
|
157
|
+
registrationAccessToken: Redacted.make(client.registration_access_token),
|
|
158
|
+
registrationClientUri: client.registration_client_uri,
|
|
159
|
+
deployment: props.deployment,
|
|
160
|
+
deploymentType: props.deploymentType,
|
|
161
|
+
redirectUri: client.redirect_uri,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/** A new client, or `saved`, or the off values. `saved` holds no off values. */
|
|
166
|
+
const newClient = (props: SharedHubClientProps) => {
|
|
167
|
+
const label = `Convex.SharedHubClient: ${props.app} on ${props.deployment} deploys without the hub`;
|
|
168
|
+
const off: SharedHubClientAttributes = {
|
|
169
|
+
status: "off",
|
|
170
|
+
issuer: "http://127.0.0.1:1",
|
|
171
|
+
clientId: "no-hub",
|
|
172
|
+
clientSecret: Redacted.make("no-hub"),
|
|
173
|
+
registrationAccessToken: Redacted.make(""),
|
|
174
|
+
registrationClientUri: "",
|
|
175
|
+
deployment: props.deployment,
|
|
176
|
+
deploymentType: props.deploymentType,
|
|
177
|
+
redirectUri: "",
|
|
178
|
+
};
|
|
179
|
+
const requestUrl = process.env["ACTIONS_ID_TOKEN_REQUEST_URL"];
|
|
180
|
+
const requestToken = process.env["ACTIONS_ID_TOKEN_REQUEST_TOKEN"];
|
|
181
|
+
// A laptop, or a repository outside Samebase. In a workflow, `id-token: write` gives both, so a
|
|
182
|
+
// workflow without them is a mistake to show, not a normal run.
|
|
183
|
+
if (!requestUrl || !requestToken) {
|
|
184
|
+
return (
|
|
185
|
+
process.env["GITHUB_ACTIONS"] === "true"
|
|
186
|
+
? Effect.logWarning(`${label}: this workflow needs the permission id-token: write.`)
|
|
187
|
+
: Effect.logInfo(`${label}: this run has no GitHub OIDC token.`)
|
|
188
|
+
).pipe(Effect.as(off));
|
|
189
|
+
}
|
|
190
|
+
return requestNewClient(props, requestUrl, requestToken).pipe(
|
|
191
|
+
Effect.catchTag("SharedHubClientFailed", (error) =>
|
|
192
|
+
Effect.logWarning(`${label}: ${error.reason}. The next deploy asks again.`).pipe(
|
|
193
|
+
Effect.as(off),
|
|
194
|
+
),
|
|
195
|
+
),
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** The client after a read on the hub, or undefined when the hub says that the client is gone. */
|
|
200
|
+
const renew = (saved: SharedHubClientAttributes) =>
|
|
201
|
+
Effect.gen(function* () {
|
|
202
|
+
const { status } = yield* send(
|
|
203
|
+
"The identity hub",
|
|
204
|
+
saved.registrationClientUri,
|
|
205
|
+
Redacted.value(saved.registrationAccessToken),
|
|
206
|
+
{ method: "GET" },
|
|
207
|
+
);
|
|
208
|
+
switch (status) {
|
|
209
|
+
case 200:
|
|
210
|
+
return { ...saved, status: "on" as const };
|
|
211
|
+
case 401:
|
|
212
|
+
return undefined;
|
|
213
|
+
case 403:
|
|
214
|
+
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.`,
|
|
216
|
+
);
|
|
217
|
+
return { ...saved, status: "suspended" as const };
|
|
218
|
+
default:
|
|
219
|
+
return yield* new SharedHubClientFailed({
|
|
220
|
+
reason: `The identity hub answered HTTP ${status}`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}).pipe(
|
|
224
|
+
Effect.catchTag("SharedHubClientFailed", (error) =>
|
|
225
|
+
Effect.logWarning(
|
|
226
|
+
`Convex.SharedHubClient: ${saved.deployment} keeps the saved client: ${error.reason}.`,
|
|
227
|
+
).pipe(Effect.as(saved)),
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
export const SharedHubClientProvider = () =>
|
|
232
|
+
Provider.succeed(SharedHubClient, {
|
|
233
|
+
// Always an update, so that each deploy reaches the hub. No attribute is stable, so a push
|
|
234
|
+
// reads the values that this run ended with. The same values cause no other push.
|
|
235
|
+
diff: ({ news }) =>
|
|
236
|
+
Effect.succeed(isResolved(news) ? ({ action: "update" } as const) : undefined),
|
|
237
|
+
|
|
238
|
+
read: ({ output }) => Effect.succeed(output),
|
|
239
|
+
|
|
240
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
241
|
+
const saved = output?.status === "off" ? undefined : output;
|
|
242
|
+
const renewed = saved?.deployment === news.deployment ? yield* renew(saved) : undefined;
|
|
243
|
+
return renewed ?? (yield* newClient(news));
|
|
244
|
+
}),
|
|
245
|
+
|
|
246
|
+
// A failed delete does not stop a destroy: the removal date of a preview client cleans it up.
|
|
247
|
+
// A destroy can leave a production deployment running, so its client stays.
|
|
248
|
+
delete: ({ output }) =>
|
|
249
|
+
output.deploymentType === "production" || output.status === "off"
|
|
250
|
+
? Effect.void
|
|
251
|
+
: Effect.gen(function* () {
|
|
252
|
+
const { status } = yield* send(
|
|
253
|
+
"The identity hub",
|
|
254
|
+
output.registrationClientUri,
|
|
255
|
+
Redacted.value(output.registrationAccessToken),
|
|
256
|
+
{ method: "DELETE" },
|
|
257
|
+
);
|
|
258
|
+
// 401: the client is gone already.
|
|
259
|
+
if (status !== 204 && status !== 401) {
|
|
260
|
+
return yield* new SharedHubClientFailed({
|
|
261
|
+
reason: `The identity hub answered HTTP ${status}`,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}).pipe(
|
|
265
|
+
Effect.catchTag("SharedHubClientFailed", (error) =>
|
|
266
|
+
Effect.logWarning(
|
|
267
|
+
`Convex.SharedHubClient: the client of ${output.deployment} stays on the hub until its removal date: ${error.reason}.`,
|
|
268
|
+
),
|
|
269
|
+
),
|
|
270
|
+
),
|
|
271
|
+
});
|
package/src/StackComment.ts
CHANGED
|
@@ -52,6 +52,10 @@ const Resource = Schema.Union([
|
|
|
52
52
|
jurisdiction: Schema.String,
|
|
53
53
|
}),
|
|
54
54
|
}),
|
|
55
|
+
Schema.Struct({
|
|
56
|
+
resourceType: Schema.Literal("IdentityHub.SharedClient"),
|
|
57
|
+
attr: Schema.Struct({ status: Schema.Literals(["on", "off", "suspended"]) }),
|
|
58
|
+
}),
|
|
55
59
|
]);
|
|
56
60
|
type Resource = typeof Resource.Type;
|
|
57
61
|
|
|
@@ -183,6 +187,10 @@ const resourceRow = (resource: Resource, address: string | null) => {
|
|
|
183
187
|
bucketName,
|
|
184
188
|
);
|
|
185
189
|
}
|
|
190
|
+
case "IdentityHub.SharedClient": {
|
|
191
|
+
const { status } = resource.attr;
|
|
192
|
+
return `Google sign-in: ${status === "off" ? "not set up" : status}`;
|
|
193
|
+
}
|
|
186
194
|
}
|
|
187
195
|
};
|
|
188
196
|
|
package/src/index.ts
CHANGED
|
@@ -46,6 +46,11 @@ export {
|
|
|
46
46
|
runRecord,
|
|
47
47
|
withRunRecord,
|
|
48
48
|
} from "./RunRecord.ts";
|
|
49
|
+
export {
|
|
50
|
+
SharedHubClient,
|
|
51
|
+
type SharedHubClientAttributes,
|
|
52
|
+
type SharedHubClientProps,
|
|
53
|
+
} from "./SharedHubClient.ts";
|
|
49
54
|
export { state, STORE_PROJECT_NAME } from "./State.ts";
|
|
50
55
|
export {
|
|
51
56
|
CodePushFailed,
|