@samebase/alchemy-convex 0.21.0 → 0.23.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 +13 -9
- package/lib/index.d.ts +7 -4
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +27 -10
- package/lib/index.js.map +1 -1
- package/package.json +10 -9
- package/src/SharedHubClient.ts +55 -21
package/README.md
CHANGED
|
@@ -14,21 +14,21 @@ the "Convex Plain" mode of
|
|
|
14
14
|
[rjdellecese/confect#408](https://github.com/rjdellecese/confect/issues/408).
|
|
15
15
|
[docs/design/convex-plane.md](docs/design/convex-plane.md) records the decisions.
|
|
16
16
|
|
|
17
|
-
Status: pinned to `alchemy@2.0.0-beta.
|
|
17
|
+
Status: pinned to `alchemy@2.0.0-beta.85` and Effect 4.1. Alchemy ships breaking changes between
|
|
18
18
|
betas; upgrade this package and Alchemy together. A live test runs the full sequence with the real
|
|
19
19
|
Convex CLI: create, push, no-op, change, preview, deploy keys alone, and destroy.
|
|
20
20
|
|
|
21
21
|
## Install
|
|
22
22
|
|
|
23
23
|
```sh
|
|
24
|
-
pnpm add -D @samebase/alchemy-convex alchemy@2.0.0-beta.
|
|
24
|
+
pnpm add -D @samebase/alchemy-convex alchemy@2.0.0-beta.85 effect@^4.1.0 @effect/platform-node@^4.1.0 convex
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
Use one version for `effect` and for each `@effect/*` package. A mix fails at load time, such as
|
|
28
28
|
`@effect/platform-node-shared` 4.0.5 next to `effect` 4.0.3.
|
|
29
29
|
|
|
30
30
|
Alchemy providers are separate packages. The example below also needs
|
|
31
|
-
`@alchemy.run/cloudflare@2.0.0-beta.
|
|
31
|
+
`@alchemy.run/cloudflare@2.0.0-beta.85`, the same version as `alchemy`.
|
|
32
32
|
|
|
33
33
|
## Example
|
|
34
34
|
|
|
@@ -308,13 +308,17 @@ call.
|
|
|
308
308
|
the `Convex.Deployment` writes to the deployment. Another `redirectUri` is a new client, and a
|
|
309
309
|
destroy deletes the client.
|
|
310
310
|
|
|
311
|
-
- `Convex.SharedHubClient`: `{ app,
|
|
312
|
-
the central identity hub through samebase.com, for an app that deploys in its own GitHub
|
|
313
|
-
run. The workflow needs `permissions: id-token: write` and no Samebase key.
|
|
314
|
-
|
|
315
|
-
`
|
|
311
|
+
- `Convex.SharedHubClient`: `{ app, redirectUri, deploymentType, softwareVersion? }` gets a client
|
|
312
|
+
of the central identity hub through samebase.com, for an app that deploys in its own GitHub
|
|
313
|
+
Actions run, on any host. The workflow needs `permissions: id-token: write` and no Samebase key.
|
|
314
|
+
The origin of `redirectUri` must serve `/samebase-verification.json` with the numeric GitHub id of
|
|
315
|
+
the repository, `{ "repository": "123" }`, which `gh api repos/<owner>/<name> --jq .id` prints.
|
|
316
|
+
Samebase reads it before it registers the client, so the site must be live first. Outputs
|
|
317
|
+
`status`, `issuer`, `clientId`, `clientSecret`, `registrationAccessToken`,
|
|
318
|
+
`registrationClientUri`, `deploymentType`, and `redirectUri`.
|
|
316
319
|
- Each deploy reads the saved client on the hub with its own token. A client that is gone gets a
|
|
317
|
-
new one, and a client on another
|
|
320
|
+
new one, and a client on another `redirectUri` too.
|
|
321
|
+
- A refusal logs its reason, such as a host that does not serve the file.
|
|
318
322
|
- No failure stops a deploy or a destroy. A failed renewal keeps the saved values. Without saved
|
|
319
323
|
values, `status` is `off`, and `issuer`, `clientId`, and `clientSecret` are the `no-hub` values
|
|
320
324
|
of a checkout without a hub. A run without a GitHub OIDC token, such as on a laptop, does the
|
package/lib/index.d.ts
CHANGED
|
@@ -505,8 +505,12 @@ export declare const withRunRecord: (output: unknown, record: RunRecord) => obje
|
|
|
505
505
|
interface SharedHubClientProps {
|
|
506
506
|
/** The stable name of the app, set once in the stack. */
|
|
507
507
|
readonly app: string;
|
|
508
|
-
/**
|
|
509
|
-
|
|
508
|
+
/**
|
|
509
|
+
* The return address of the hub's sign-ins, such as `${site.siteUrl}/auth/hub/callback`: an https
|
|
510
|
+
* URL whose origin serves `/samebase-verification.json` with the numeric GitHub id of this
|
|
511
|
+
* repository, `{ "repository": "123" }`. It can come from a resource that this run creates.
|
|
512
|
+
*/
|
|
513
|
+
readonly redirectUri: string;
|
|
510
514
|
readonly deploymentType: "production" | "preview";
|
|
511
515
|
/** Recorded on the hub's client, such as the commit. The hub records "unknown" without it. */
|
|
512
516
|
readonly softwareVersion?: string;
|
|
@@ -522,9 +526,8 @@ interface SharedHubClientAttributes {
|
|
|
522
526
|
/** Returned only at registration. It reads and deletes this client on the hub, and nothing else. */
|
|
523
527
|
readonly registrationAccessToken: Redacted.Redacted<string>;
|
|
524
528
|
readonly registrationClientUri: string;
|
|
525
|
-
/** The deployment that the client serves. */
|
|
526
|
-
readonly deployment: string;
|
|
527
529
|
readonly deploymentType: "production" | "preview";
|
|
530
|
+
/** The return address that the client serves. Empty for the off values. */
|
|
528
531
|
readonly redirectUri: string;
|
|
529
532
|
}
|
|
530
533
|
export type SharedHubClient = Resource<"IdentityHub.SharedClient", SharedHubClientProps, SharedHubClientAttributes, never, Providers>;
|
package/lib/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/Credentials.ts","../src/ManagementApi.ts","../src/Providers.ts","../src/Push.ts","../src/Deployment.ts","../src/Component.ts","../src/Project.ts","../src/HubClient.ts","../src/RunRecord.ts","../src/SharedHubClient.ts","../src/State.ts"],"mappings":"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
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/Credentials.ts","../src/ManagementApi.ts","../src/Providers.ts","../src/Push.ts","../src/Deployment.ts","../src/Component.ts","../src/Project.ts","../src/HubClient.ts","../src/RunRecord.ts","../src/SharedHubClient.ts","../src/State.ts"],"mappings":"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"}
|
package/lib/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { Stack } from "alchemy/Stack";
|
|
|
4
4
|
import * as Effect from "effect/Effect";
|
|
5
5
|
import * as Schema from "effect/Schema";
|
|
6
6
|
import * as Layer from "effect/Layer";
|
|
7
|
+
import * as Option from "effect/Option";
|
|
7
8
|
import * as Redacted from "effect/Redacted";
|
|
8
9
|
import { Resource } from "alchemy";
|
|
9
10
|
import { isResolved } from "alchemy/Diff";
|
|
@@ -51,12 +52,30 @@ const NewClient = Schema.Struct({
|
|
|
51
52
|
});
|
|
52
53
|
/** The other answers of `POST /api/hub-clients`, by status. */
|
|
53
54
|
const REFUSALS = {
|
|
55
|
+
400: "Samebase refused the request: the return address must be https on a DNS name",
|
|
54
56
|
401: "Samebase refused the GitHub OIDC token",
|
|
55
|
-
403: "Samebase does not allow this repository or
|
|
57
|
+
403: "Samebase does not allow this repository or return address",
|
|
56
58
|
404: "hub clients are off on Samebase",
|
|
57
59
|
429: "Samebase limits the requests of this repository",
|
|
58
60
|
502: "the identity hub is not available"
|
|
59
61
|
};
|
|
62
|
+
/** The reasons of a 403 that a log line shows. Any other text of the answer is never logged. */
|
|
63
|
+
const NOT_ALLOWED = /* @__PURE__ */ new Map([
|
|
64
|
+
["run_not_allowed", "Samebase gives clients only to runs of main and of pull requests"],
|
|
65
|
+
["repository_not_connected", "the repository is not connected to Samebase"],
|
|
66
|
+
["owner_mismatch", "the repository's owner is not the owner connected to Samebase"],
|
|
67
|
+
["host_unreachable", "Samebase could not reach the host of the return address"],
|
|
68
|
+
["verification_file_missing", "the host of the return address does not serve /samebase-verification.json"],
|
|
69
|
+
["verification_file_invalid", "/samebase-verification.json on the host holds no repository id"],
|
|
70
|
+
["repository_mismatch", "/samebase-verification.json on the host names another repository"],
|
|
71
|
+
["hub_refused", "the identity hub refused this repository"]
|
|
72
|
+
]);
|
|
73
|
+
const Refusal = Schema.Struct({ reason: Schema.String });
|
|
74
|
+
/** The text of a refusal: a known reason of a 403, else the text of its status. */
|
|
75
|
+
const refusalOf = (status, body) => {
|
|
76
|
+
const reason = status === 403 ? Option.getOrUndefined(Schema.decodeUnknownOption(Refusal)(body))?.reason : void 0;
|
|
77
|
+
return (reason === void 0 ? void 0 : NOT_ALLOWED.get(reason)) ?? REFUSALS[status] ?? "Samebase refused the request";
|
|
78
|
+
};
|
|
60
79
|
const send = (who, url, token, init) => Effect.tryPromise({
|
|
61
80
|
try: async () => {
|
|
62
81
|
const response = await fetch(url, {
|
|
@@ -88,11 +107,11 @@ const requestNewClient = (props, requestUrl, requestToken) => Effect.gen(functio
|
|
|
88
107
|
method: "POST",
|
|
89
108
|
body: JSON.stringify({
|
|
90
109
|
app: props.app,
|
|
91
|
-
|
|
110
|
+
redirectUri: props.redirectUri,
|
|
92
111
|
softwareVersion: props.softwareVersion ?? "unknown"
|
|
93
112
|
})
|
|
94
113
|
});
|
|
95
|
-
if (status !== 201) return yield* new SharedHubClientFailed({ reason: `${
|
|
114
|
+
if (status !== 201) return yield* new SharedHubClientFailed({ reason: `${refusalOf(status, body)} (HTTP ${status})` });
|
|
96
115
|
const client = yield* decode(NewClient, "Samebase")(body);
|
|
97
116
|
return {
|
|
98
117
|
status: "on",
|
|
@@ -101,14 +120,13 @@ const requestNewClient = (props, requestUrl, requestToken) => Effect.gen(functio
|
|
|
101
120
|
clientSecret: Redacted.make(client.client_secret),
|
|
102
121
|
registrationAccessToken: Redacted.make(client.registration_access_token),
|
|
103
122
|
registrationClientUri: client.registration_client_uri,
|
|
104
|
-
deployment: props.deployment,
|
|
105
123
|
deploymentType: props.deploymentType,
|
|
106
124
|
redirectUri: client.redirect_uri
|
|
107
125
|
};
|
|
108
126
|
});
|
|
109
127
|
/** A new client, or `saved`, or the off values. `saved` holds no off values. */
|
|
110
128
|
const newClient = (props) => {
|
|
111
|
-
const label = `Convex.SharedHubClient: ${props.app} on ${props.
|
|
129
|
+
const label = `Convex.SharedHubClient: ${props.app} on ${props.redirectUri} deploys without the hub`;
|
|
112
130
|
const off = {
|
|
113
131
|
status: "off",
|
|
114
132
|
issuer: "http://127.0.0.1:1",
|
|
@@ -116,7 +134,6 @@ const newClient = (props) => {
|
|
|
116
134
|
clientSecret: Redacted.make("no-hub"),
|
|
117
135
|
registrationAccessToken: Redacted.make(""),
|
|
118
136
|
registrationClientUri: "",
|
|
119
|
-
deployment: props.deployment,
|
|
120
137
|
deploymentType: props.deploymentType,
|
|
121
138
|
redirectUri: ""
|
|
122
139
|
};
|
|
@@ -135,25 +152,25 @@ const renew = (saved) => Effect.gen(function* () {
|
|
|
135
152
|
};
|
|
136
153
|
case 401: return;
|
|
137
154
|
case 403:
|
|
138
|
-
yield* Effect.logWarning(`Convex.SharedHubClient: the hub suspended the client of ${saved.
|
|
155
|
+
yield* Effect.logWarning(`Convex.SharedHubClient: the hub suspended the client of ${saved.redirectUri}. It keeps its values, and nobody can sign in with it.`);
|
|
139
156
|
return {
|
|
140
157
|
...saved,
|
|
141
158
|
status: "suspended"
|
|
142
159
|
};
|
|
143
160
|
default: return yield* new SharedHubClientFailed({ reason: `The identity hub answered HTTP ${status}` });
|
|
144
161
|
}
|
|
145
|
-
}).pipe(Effect.catchTag("SharedHubClientFailed", (error) => Effect.logWarning(`Convex.SharedHubClient: ${saved.
|
|
162
|
+
}).pipe(Effect.catchTag("SharedHubClientFailed", (error) => Effect.logWarning(`Convex.SharedHubClient: ${saved.redirectUri} keeps the saved client: ${error.reason}.`).pipe(Effect.as(saved))));
|
|
146
163
|
const SharedHubClientProvider = () => Provider.succeed(SharedHubClient, {
|
|
147
164
|
diff: ({ news }) => Effect.succeed(isResolved(news) ? { action: "update" } : void 0),
|
|
148
165
|
read: ({ output }) => Effect.succeed(output),
|
|
149
166
|
reconcile: Effect.fn(function* ({ news, output }) {
|
|
150
167
|
const saved = output?.status === "off" ? void 0 : output;
|
|
151
|
-
return (saved?.
|
|
168
|
+
return (saved?.redirectUri === news.redirectUri ? yield* renew(saved) : void 0) ?? (yield* newClient(news));
|
|
152
169
|
}),
|
|
153
170
|
delete: ({ output }) => output.deploymentType === "production" || output.status === "off" ? Effect.void : Effect.gen(function* () {
|
|
154
171
|
const { status } = yield* send("The identity hub", output.registrationClientUri, Redacted.value(output.registrationAccessToken), { method: "DELETE" });
|
|
155
172
|
if (status !== 204 && status !== 401) return yield* new SharedHubClientFailed({ reason: `The identity hub answered HTTP ${status}` });
|
|
156
|
-
}).pipe(Effect.catchTag("SharedHubClientFailed", (error) => Effect.logWarning(`Convex.SharedHubClient: the client of ${output.
|
|
173
|
+
}).pipe(Effect.catchTag("SharedHubClientFailed", (error) => Effect.logWarning(`Convex.SharedHubClient: the client of ${output.redirectUri} stays on the hub until its removal date: ${error.reason}.`)))
|
|
157
174
|
});
|
|
158
175
|
//#endregion
|
|
159
176
|
//#region src/Providers.ts
|
package/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/Component.ts","../src/SharedHubClient.ts","../src/Providers.ts"],"sourcesContent":["import { toPath } from \"alchemy/FQN\";\nimport type { NamespaceNode } from \"alchemy/Namespace\";\nimport { Stack } from \"alchemy/Stack\";\nimport * as Effect from \"effect/Effect\";\nimport * as Schema from \"effect/Schema\";\nimport type { Deployment } from \"./Deployment.ts\";\n\n/** A component's resources occupy this namespace in the deployment's Alchemy stack and stage. */\nexport interface ComponentBinding {\n readonly type: \"component\";\n readonly path: string;\n readonly namespace: readonly string[];\n}\n\n/**\n * Associate a native namespace with an installed component. `component` is the\n * installed name from the app's `convex.config.ts`; a nested install joins the\n * names with slashes, such as `\"workspace/mail\"`. An undefined namespace\n * associates the stack root. This records display metadata; it does not install\n * the component, set its environment, or change removal policies.\n */\nexport const bindComponent = (options: {\n deployment: Deployment;\n component: string;\n namespace: NamespaceNode | undefined;\n}) =>\n Effect.gen(function* () {\n if (options.component === \"\") {\n return yield* Effect.die(new Error(\"The component name is empty.\"));\n }\n const namespace = toPath(options.namespace);\n const sid = `convex.component:${JSON.stringify(namespace)}`;\n const stack = yield* Stack;\n const previous = stack.bindings[options.deployment.FQN]?.find((binding) => binding.sid === sid);\n // Alchemy keeps the last binding with a given sid. Reject conflicting owners before that happens.\n if (previous !== undefined) {\n const existing = Schema.decodeUnknownSync(Schema.Struct({ path: Schema.String }))(\n previous.data,\n );\n if (existing.path !== options.component) {\n return yield* Effect.die(\n new Error(\n `Alchemy namespace ${JSON.stringify(namespace)} is already bound to ${existing.path}.`,\n ),\n );\n }\n return;\n }\n yield* options.deployment.bind(sid, {\n type: \"component\",\n path: options.component,\n namespace,\n });\n });\n","// Convex.SharedHubClient: one deployment of an app as a client of the central identity hub, for an\n// app that deploys in its own GitHub Actions run.\n//\n// The run asks samebase.com for a new client with a GitHub OIDC token, so the stack needs no\n// Samebase key. Samebase checks the token, chooses the callback, and registers the client. The\n// answer holds the client's own registration access token (RFC 7591), and each later deploy reads\n// the client on the hub with that token (RFC 7592). The hub keeps only hashes, so the state is the\n// only record of the secret and the token.\n//\n// A push removes a variable that a deploy stops passing, so a failure here must not drop working\n// variables or stop the deploy. A renewal that fails keeps the saved values. Only a client that\n// cannot work asks for a new one: it is gone, or it has the callback of another deployment. If that\n// request fails, the run gives the off values, so the app has guest sign-in and not a broken button.\n// The off values are the `no-hub` variables of a checkout without a hub: Convex needs the variables,\n// and the app hides the button for this client ID.\n//\n// No text in this file quotes an answer or a fetch error, so no log line can hold a secret.\nimport { Resource } from \"alchemy\";\nimport { isResolved } from \"alchemy/Diff\";\nimport * as Provider from \"alchemy/Provider\";\nimport * as Effect from \"effect/Effect\";\nimport * as Redacted from \"effect/Redacted\";\nimport * as Schema from \"effect/Schema\";\nimport type { Providers } from \"./Providers.ts\";\n\n/** A hung service must not hold a deploy: the time limit of one request. */\nconst REQUEST_TIMEOUT_MS = 30_000;\n\nexport interface SharedHubClientProps {\n /** The stable name of the app, set once in the stack. */\n readonly app: string;\n /** The Convex deployment name. It can come from a deployment that this run creates. */\n readonly deployment: string;\n readonly deploymentType: \"production\" | \"preview\";\n /** Recorded on the hub's client, such as the commit. The hub records \"unknown\" without it. */\n readonly softwareVersion?: string;\n}\n\nexport interface SharedHubClientAttributes {\n /** `off`: no client, and the values below are the off values. */\n readonly status: \"on\" | \"off\" | \"suspended\";\n /** The issuer of the hub, which the deployment names to sign in. */\n readonly issuer: string;\n readonly clientId: string;\n /** Returned only at registration. */\n readonly clientSecret: Redacted.Redacted<string>;\n /** Returned only at registration. It reads and deletes this client on the hub, and nothing else. */\n readonly registrationAccessToken: Redacted.Redacted<string>;\n readonly registrationClientUri: string;\n /** The deployment that the client serves. */\n readonly deployment: string;\n readonly deploymentType: \"production\" | \"preview\";\n readonly redirectUri: string;\n}\n\nexport type SharedHubClient = Resource<\n \"IdentityHub.SharedClient\",\n SharedHubClientProps,\n SharedHubClientAttributes,\n never,\n Providers\n>;\nexport const SharedHubClient = Resource<SharedHubClient>(\"IdentityHub.SharedClient\");\n\n/** One request gave no usable answer. The reason holds no secret. */\nclass SharedHubClientFailed extends Schema.TaggedError<SharedHubClientFailed>()(\n \"SharedHubClientFailed\",\n { reason: Schema.String },\n) {}\n\nconst OidcToken = Schema.Struct({ value: Schema.NonEmptyString });\n\n/** The 201 answer of `POST /api/hub-clients`. */\nconst NewClient = Schema.Struct({\n issuer: Schema.NonEmptyString,\n client_id: Schema.NonEmptyString,\n client_secret: Schema.NonEmptyString,\n registration_access_token: Schema.NonEmptyString,\n registration_client_uri: Schema.NonEmptyString,\n redirect_uri: Schema.NonEmptyString,\n});\n\n/** The other answers of `POST /api/hub-clients`, by status. */\nconst REFUSALS: Readonly<Record<number, string>> = {\n 401: \"Samebase refused the GitHub OIDC token\",\n 403: \"Samebase does not allow this repository or deployment\",\n 404: \"hub clients are off on Samebase\",\n 429: \"Samebase limits the requests of this repository\",\n 502: \"the identity hub is not available\",\n};\n\nconst send = (who: string, url: string, token: string, init: { method: string; body?: string }) =>\n Effect.tryPromise({\n try: async () => {\n const response = await fetch(url, {\n ...init,\n headers: { Authorization: `Bearer ${token}`, \"Content-Type\": \"application/json\" },\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n });\n const body: unknown = await response.json().catch(() => undefined);\n return { status: response.status, body };\n },\n catch: () => new SharedHubClientFailed({ reason: `${who} gave no answer` }),\n });\n\nconst decode =\n <S extends Schema.Codec<unknown, unknown>>(schema: S, who: string) =>\n (value: unknown) =>\n Schema.decodeUnknownEffect(schema)(value).pipe(\n Effect.mapError(() => new SharedHubClientFailed({ reason: `${who} gave a bad answer` })),\n );\n\n/** The OIDC token of this run, read right before the call, because it lives for minutes only. */\nconst readOidcToken = (requestUrl: string, requestToken: string) =>\n Effect.gen(function* () {\n const { body, status } = yield* send(\n \"GitHub\",\n `${requestUrl}&audience=samebase`,\n requestToken,\n { method: \"GET\" },\n );\n if (status !== 200) {\n return yield* new SharedHubClientFailed({\n reason: `GitHub gave no OIDC token (HTTP ${status})`,\n });\n }\n return (yield* decode(OidcToken, \"GitHub\")(body)).value;\n });\n\nconst requestNewClient = (props: SharedHubClientProps, requestUrl: string, requestToken: string) =>\n Effect.gen(function* () {\n const token = yield* readOidcToken(requestUrl, requestToken);\n const { body, status } = yield* send(\n \"Samebase\",\n `${process.env[\"SAMEBASE_URL\"] ?? \"https://samebase.com\"}/api/hub-clients`,\n token,\n {\n method: \"POST\",\n body: JSON.stringify({\n app: props.app,\n deployment: props.deployment,\n softwareVersion: props.softwareVersion ?? \"unknown\",\n }),\n },\n );\n if (status !== 201) {\n return yield* new SharedHubClientFailed({\n reason: `${REFUSALS[status] ?? \"Samebase refused the request\"} (HTTP ${status})`,\n });\n }\n const client = yield* decode(NewClient, \"Samebase\")(body);\n return {\n status: \"on\" as const,\n issuer: client.issuer,\n clientId: client.client_id,\n clientSecret: Redacted.make(client.client_secret),\n registrationAccessToken: Redacted.make(client.registration_access_token),\n registrationClientUri: client.registration_client_uri,\n deployment: props.deployment,\n deploymentType: props.deploymentType,\n redirectUri: client.redirect_uri,\n };\n });\n\n/** A new client, or `saved`, or the off values. `saved` holds no off values. */\nconst newClient = (props: SharedHubClientProps) => {\n const label = `Convex.SharedHubClient: ${props.app} on ${props.deployment} deploys without the hub`;\n const off: SharedHubClientAttributes = {\n status: \"off\",\n issuer: \"http://127.0.0.1:1\",\n clientId: \"no-hub\",\n clientSecret: Redacted.make(\"no-hub\"),\n registrationAccessToken: Redacted.make(\"\"),\n registrationClientUri: \"\",\n deployment: props.deployment,\n deploymentType: props.deploymentType,\n redirectUri: \"\",\n };\n const requestUrl = process.env[\"ACTIONS_ID_TOKEN_REQUEST_URL\"];\n const requestToken = process.env[\"ACTIONS_ID_TOKEN_REQUEST_TOKEN\"];\n // A laptop, or a repository outside Samebase. In a workflow, `id-token: write` gives both, so a\n // workflow without them is a mistake to show, not a normal run.\n if (!requestUrl || !requestToken) {\n return (\n process.env[\"GITHUB_ACTIONS\"] === \"true\"\n ? Effect.logWarning(`${label}: this workflow needs the permission id-token: write.`)\n : Effect.logInfo(`${label}: this run has no GitHub OIDC token.`)\n ).pipe(Effect.as(off));\n }\n return requestNewClient(props, requestUrl, requestToken).pipe(\n Effect.catchTag(\"SharedHubClientFailed\", (error) =>\n Effect.logWarning(`${label}: ${error.reason}. The next deploy asks again.`).pipe(\n Effect.as(off),\n ),\n ),\n );\n};\n\n/** The client after a read on the hub, or undefined when the hub says that the client is gone. */\nconst renew = (saved: SharedHubClientAttributes) =>\n Effect.gen(function* () {\n const { status } = yield* send(\n \"The identity hub\",\n saved.registrationClientUri,\n Redacted.value(saved.registrationAccessToken),\n { method: \"GET\" },\n );\n switch (status) {\n case 200:\n return { ...saved, status: \"on\" as const };\n case 401:\n return undefined;\n case 403:\n yield* Effect.logWarning(\n `Convex.SharedHubClient: the hub suspended the client of ${saved.deployment}. It keeps its values, and nobody can sign in with it.`,\n );\n return { ...saved, status: \"suspended\" as const };\n default:\n return yield* new SharedHubClientFailed({\n reason: `The identity hub answered HTTP ${status}`,\n });\n }\n }).pipe(\n Effect.catchTag(\"SharedHubClientFailed\", (error) =>\n Effect.logWarning(\n `Convex.SharedHubClient: ${saved.deployment} keeps the saved client: ${error.reason}.`,\n ).pipe(Effect.as(saved)),\n ),\n );\n\nexport const SharedHubClientProvider = () =>\n Provider.succeed(SharedHubClient, {\n // Always an update, so that each deploy reaches the hub. No attribute is stable, so a push\n // reads the values that this run ended with. The same values cause no other push.\n diff: ({ news }) =>\n Effect.succeed(isResolved(news) ? ({ action: \"update\" } as const) : undefined),\n\n read: ({ output }) => Effect.succeed(output),\n\n reconcile: Effect.fn(function* ({ news, output }) {\n const saved = output?.status === \"off\" ? undefined : output;\n const renewed = saved?.deployment === news.deployment ? yield* renew(saved) : undefined;\n return renewed ?? (yield* newClient(news));\n }),\n\n // A failed delete does not stop a destroy: the removal date of a preview client cleans it up.\n // A destroy can leave a production deployment running, so its client stays.\n delete: ({ output }) =>\n output.deploymentType === \"production\" || output.status === \"off\"\n ? Effect.void\n : Effect.gen(function* () {\n const { status } = yield* send(\n \"The identity hub\",\n output.registrationClientUri,\n Redacted.value(output.registrationAccessToken),\n { method: \"DELETE\" },\n );\n // 401: the client is gone already.\n if (status !== 204 && status !== 401) {\n return yield* new SharedHubClientFailed({\n reason: `The identity hub answered HTTP ${status}`,\n });\n }\n }).pipe(\n Effect.catchTag(\"SharedHubClientFailed\", (error) =>\n Effect.logWarning(\n `Convex.SharedHubClient: the client of ${output.deployment} stays on the hub until its removal date: ${error.reason}.`,\n ),\n ),\n ),\n });\n","// The provider collection users merge into a stack:\n// providers: Layer.mergeAll(Cloudflare.providers(), Convex.providers())\nimport * as Provider from \"alchemy/Provider\";\nimport * as Layer from \"effect/Layer\";\nimport { type Credentials, fromConfig } from \"./Credentials.ts\";\nimport { DeploymentProvider, DeploymentResource } from \"./Deployment.ts\";\nimport { HubClient, HubClientProvider } from \"./HubClient.ts\";\nimport { ManagementApiLive } from \"./ManagementApi.ts\";\nimport { Project, ProjectProvider } from \"./Project.ts\";\nimport { SharedHubClient, SharedHubClientProvider } from \"./SharedHubClient.ts\";\n\nexport class Providers extends Provider.ProviderCollection<Providers>()(\"Convex\") {}\n\n/**\n * The Convex resource providers. Pass a credentials layer to override the\n * default lookup (`CONVEX_ACCESS_TOKEN`, then the Convex CLI login, and\n * `CONVEX_DEPLOY_KEY`).\n */\nexport const providers = (credentials: Layer.Layer<Credentials> = fromConfig()) =>\n Layer.effect(\n Providers,\n Provider.collection([Project, DeploymentResource, HubClient, SharedHubClient]),\n ).pipe(\n Layer.provide(\n Layer.mergeAll(\n ProjectProvider(),\n DeploymentProvider(),\n HubClientProvider(),\n SharedHubClientProvider(),\n ),\n ),\n Layer.provide(ManagementApiLive()),\n Layer.provide(credentials),\n Layer.orDie,\n );\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/Component.ts","../src/SharedHubClient.ts","../src/Providers.ts"],"sourcesContent":["import { toPath } from \"alchemy/FQN\";\nimport type { NamespaceNode } from \"alchemy/Namespace\";\nimport { Stack } from \"alchemy/Stack\";\nimport * as Effect from \"effect/Effect\";\nimport * as Schema from \"effect/Schema\";\nimport type { Deployment } from \"./Deployment.ts\";\n\n/** A component's resources occupy this namespace in the deployment's Alchemy stack and stage. */\nexport interface ComponentBinding {\n readonly type: \"component\";\n readonly path: string;\n readonly namespace: readonly string[];\n}\n\n/**\n * Associate a native namespace with an installed component. `component` is the\n * installed name from the app's `convex.config.ts`; a nested install joins the\n * names with slashes, such as `\"workspace/mail\"`. An undefined namespace\n * associates the stack root. This records display metadata; it does not install\n * the component, set its environment, or change removal policies.\n */\nexport const bindComponent = (options: {\n deployment: Deployment;\n component: string;\n namespace: NamespaceNode | undefined;\n}) =>\n Effect.gen(function* () {\n if (options.component === \"\") {\n return yield* Effect.die(new Error(\"The component name is empty.\"));\n }\n const namespace = toPath(options.namespace);\n const sid = `convex.component:${JSON.stringify(namespace)}`;\n const stack = yield* Stack;\n const previous = stack.bindings[options.deployment.FQN]?.find((binding) => binding.sid === sid);\n // Alchemy keeps the last binding with a given sid. Reject conflicting owners before that happens.\n if (previous !== undefined) {\n const existing = Schema.decodeUnknownSync(Schema.Struct({ path: Schema.String }))(\n previous.data,\n );\n if (existing.path !== options.component) {\n return yield* Effect.die(\n new Error(\n `Alchemy namespace ${JSON.stringify(namespace)} is already bound to ${existing.path}.`,\n ),\n );\n }\n return;\n }\n yield* options.deployment.bind(sid, {\n type: \"component\",\n path: options.component,\n namespace,\n });\n });\n","// Convex.SharedHubClient: one deployment of an app as a client of the central identity hub, for an\n// app that deploys in its own GitHub Actions run, on any host.\n//\n// The run asks samebase.com for a new client with a GitHub OIDC token, so the stack needs no\n// Samebase key. The token names the repository. Samebase reads `/samebase-verification.json` at the\n// origin of the return address, which must name the same repository, and registers the client on\n// that address. The answer holds the client's own registration access token (RFC 7591), and each\n// later deploy reads the client on the hub with that token (RFC 7592). The hub keeps only hashes, so\n// the state is the only record of the secret and the token.\n//\n// A push removes a variable that a deploy stops passing, so a failure here must not drop working\n// variables or stop the deploy. A renewal that fails keeps the saved values. Only a client that\n// cannot work asks for a new one: it is gone, or it has another return address. If that request\n// fails, the run gives the off values, so the app has guest sign-in and not a broken button.\n// The off values are the `no-hub` variables of a checkout without a hub: Convex needs the variables,\n// and the app hides the button for this client ID.\n//\n// No text in this file quotes an answer or a fetch error, so no log line can hold a secret.\nimport { Resource } from \"alchemy\";\nimport { isResolved } from \"alchemy/Diff\";\nimport * as Provider from \"alchemy/Provider\";\nimport * as Effect from \"effect/Effect\";\nimport * as Option from \"effect/Option\";\nimport * as Redacted from \"effect/Redacted\";\nimport * as Schema from \"effect/Schema\";\nimport type { Providers } from \"./Providers.ts\";\n\n/** A hung service must not hold a deploy: the time limit of one request. */\nconst REQUEST_TIMEOUT_MS = 30_000;\n\nexport interface SharedHubClientProps {\n /** The stable name of the app, set once in the stack. */\n readonly app: string;\n /**\n * The return address of the hub's sign-ins, such as `${site.siteUrl}/auth/hub/callback`: an https\n * URL whose origin serves `/samebase-verification.json` with the numeric GitHub id of this\n * repository, `{ \"repository\": \"123\" }`. It can come from a resource that this run creates.\n */\n readonly redirectUri: string;\n readonly deploymentType: \"production\" | \"preview\";\n /** Recorded on the hub's client, such as the commit. The hub records \"unknown\" without it. */\n readonly softwareVersion?: string;\n}\n\nexport interface SharedHubClientAttributes {\n /** `off`: no client, and the values below are the off values. */\n readonly status: \"on\" | \"off\" | \"suspended\";\n /** The issuer of the hub, which the deployment names to sign in. */\n readonly issuer: string;\n readonly clientId: string;\n /** Returned only at registration. */\n readonly clientSecret: Redacted.Redacted<string>;\n /** Returned only at registration. It reads and deletes this client on the hub, and nothing else. */\n readonly registrationAccessToken: Redacted.Redacted<string>;\n readonly registrationClientUri: string;\n readonly deploymentType: \"production\" | \"preview\";\n /** The return address that the client serves. Empty for the off values. */\n readonly redirectUri: string;\n}\n\nexport type SharedHubClient = Resource<\n \"IdentityHub.SharedClient\",\n SharedHubClientProps,\n SharedHubClientAttributes,\n never,\n Providers\n>;\nexport const SharedHubClient = Resource<SharedHubClient>(\"IdentityHub.SharedClient\");\n\n/** One request gave no usable answer. The reason holds no secret. */\nclass SharedHubClientFailed extends Schema.TaggedError<SharedHubClientFailed>()(\n \"SharedHubClientFailed\",\n { reason: Schema.String },\n) {}\n\nconst OidcToken = Schema.Struct({ value: Schema.NonEmptyString });\n\n/** The 201 answer of `POST /api/hub-clients`. */\nconst NewClient = Schema.Struct({\n issuer: Schema.NonEmptyString,\n client_id: Schema.NonEmptyString,\n client_secret: Schema.NonEmptyString,\n registration_access_token: Schema.NonEmptyString,\n registration_client_uri: Schema.NonEmptyString,\n redirect_uri: Schema.NonEmptyString,\n});\n\n/** The other answers of `POST /api/hub-clients`, by status. */\nconst REFUSALS: Readonly<Record<number, string>> = {\n 400: \"Samebase refused the request: the return address must be https on a DNS name\",\n 401: \"Samebase refused the GitHub OIDC token\",\n 403: \"Samebase does not allow this repository or return address\",\n 404: \"hub clients are off on Samebase\",\n 429: \"Samebase limits the requests of this repository\",\n 502: \"the identity hub is not available\",\n};\n\n/** The reasons of a 403 that a log line shows. Any other text of the answer is never logged. */\nconst NOT_ALLOWED = new Map([\n [\"run_not_allowed\", \"Samebase gives clients only to runs of main and of pull requests\"],\n [\"repository_not_connected\", \"the repository is not connected to Samebase\"],\n [\"owner_mismatch\", \"the repository's owner is not the owner connected to Samebase\"],\n [\"host_unreachable\", \"Samebase could not reach the host of the return address\"],\n [\n \"verification_file_missing\",\n \"the host of the return address does not serve /samebase-verification.json\",\n ],\n [\"verification_file_invalid\", \"/samebase-verification.json on the host holds no repository id\"],\n [\"repository_mismatch\", \"/samebase-verification.json on the host names another repository\"],\n [\"hub_refused\", \"the identity hub refused this repository\"],\n]);\n\nconst Refusal = Schema.Struct({ reason: Schema.String });\n\n/** The text of a refusal: a known reason of a 403, else the text of its status. */\nconst refusalOf = (status: number, body: unknown) => {\n const reason =\n status === 403\n ? Option.getOrUndefined(Schema.decodeUnknownOption(Refusal)(body))?.reason\n : undefined;\n return (\n (reason === undefined ? undefined : NOT_ALLOWED.get(reason)) ??\n REFUSALS[status] ??\n \"Samebase refused the request\"\n );\n};\n\nconst send = (who: string, url: string, token: string, init: { method: string; body?: string }) =>\n Effect.tryPromise({\n try: async () => {\n const response = await fetch(url, {\n ...init,\n headers: { Authorization: `Bearer ${token}`, \"Content-Type\": \"application/json\" },\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n });\n const body: unknown = await response.json().catch(() => undefined);\n return { status: response.status, body };\n },\n catch: () => new SharedHubClientFailed({ reason: `${who} gave no answer` }),\n });\n\nconst decode =\n <S extends Schema.Codec<unknown, unknown>>(schema: S, who: string) =>\n (value: unknown) =>\n Schema.decodeUnknownEffect(schema)(value).pipe(\n Effect.mapError(() => new SharedHubClientFailed({ reason: `${who} gave a bad answer` })),\n );\n\n/** The OIDC token of this run, read right before the call, because it lives for minutes only. */\nconst readOidcToken = (requestUrl: string, requestToken: string) =>\n Effect.gen(function* () {\n const { body, status } = yield* send(\n \"GitHub\",\n `${requestUrl}&audience=samebase`,\n requestToken,\n { method: \"GET\" },\n );\n if (status !== 200) {\n return yield* new SharedHubClientFailed({\n reason: `GitHub gave no OIDC token (HTTP ${status})`,\n });\n }\n return (yield* decode(OidcToken, \"GitHub\")(body)).value;\n });\n\nconst requestNewClient = (props: SharedHubClientProps, requestUrl: string, requestToken: string) =>\n Effect.gen(function* () {\n const token = yield* readOidcToken(requestUrl, requestToken);\n const { body, status } = yield* send(\n \"Samebase\",\n `${process.env[\"SAMEBASE_URL\"] ?? \"https://samebase.com\"}/api/hub-clients`,\n token,\n {\n method: \"POST\",\n body: JSON.stringify({\n app: props.app,\n redirectUri: props.redirectUri,\n softwareVersion: props.softwareVersion ?? \"unknown\",\n }),\n },\n );\n if (status !== 201) {\n return yield* new SharedHubClientFailed({\n reason: `${refusalOf(status, body)} (HTTP ${status})`,\n });\n }\n const client = yield* decode(NewClient, \"Samebase\")(body);\n return {\n status: \"on\" as const,\n issuer: client.issuer,\n clientId: client.client_id,\n clientSecret: Redacted.make(client.client_secret),\n registrationAccessToken: Redacted.make(client.registration_access_token),\n registrationClientUri: client.registration_client_uri,\n deploymentType: props.deploymentType,\n redirectUri: client.redirect_uri,\n };\n });\n\n/** A new client, or `saved`, or the off values. `saved` holds no off values. */\nconst newClient = (props: SharedHubClientProps) => {\n const label = `Convex.SharedHubClient: ${props.app} on ${props.redirectUri} deploys without the hub`;\n const off: SharedHubClientAttributes = {\n status: \"off\",\n issuer: \"http://127.0.0.1:1\",\n clientId: \"no-hub\",\n clientSecret: Redacted.make(\"no-hub\"),\n registrationAccessToken: Redacted.make(\"\"),\n registrationClientUri: \"\",\n deploymentType: props.deploymentType,\n redirectUri: \"\",\n };\n const requestUrl = process.env[\"ACTIONS_ID_TOKEN_REQUEST_URL\"];\n const requestToken = process.env[\"ACTIONS_ID_TOKEN_REQUEST_TOKEN\"];\n // A laptop, or a repository outside Samebase. In a workflow, `id-token: write` gives both, so a\n // workflow without them is a mistake to show, not a normal run.\n if (!requestUrl || !requestToken) {\n return (\n process.env[\"GITHUB_ACTIONS\"] === \"true\"\n ? Effect.logWarning(`${label}: this workflow needs the permission id-token: write.`)\n : Effect.logInfo(`${label}: this run has no GitHub OIDC token.`)\n ).pipe(Effect.as(off));\n }\n return requestNewClient(props, requestUrl, requestToken).pipe(\n Effect.catchTag(\"SharedHubClientFailed\", (error) =>\n Effect.logWarning(`${label}: ${error.reason}. The next deploy asks again.`).pipe(\n Effect.as(off),\n ),\n ),\n );\n};\n\n/** The client after a read on the hub, or undefined when the hub says that the client is gone. */\nconst renew = (saved: SharedHubClientAttributes) =>\n Effect.gen(function* () {\n const { status } = yield* send(\n \"The identity hub\",\n saved.registrationClientUri,\n Redacted.value(saved.registrationAccessToken),\n { method: \"GET\" },\n );\n switch (status) {\n case 200:\n return { ...saved, status: \"on\" as const };\n case 401:\n return undefined;\n case 403:\n yield* Effect.logWarning(\n `Convex.SharedHubClient: the hub suspended the client of ${saved.redirectUri}. It keeps its values, and nobody can sign in with it.`,\n );\n return { ...saved, status: \"suspended\" as const };\n default:\n return yield* new SharedHubClientFailed({\n reason: `The identity hub answered HTTP ${status}`,\n });\n }\n }).pipe(\n Effect.catchTag(\"SharedHubClientFailed\", (error) =>\n Effect.logWarning(\n `Convex.SharedHubClient: ${saved.redirectUri} keeps the saved client: ${error.reason}.`,\n ).pipe(Effect.as(saved)),\n ),\n );\n\nexport const SharedHubClientProvider = () =>\n Provider.succeed(SharedHubClient, {\n // Always an update, so that each deploy reaches the hub. No attribute is stable, so a push\n // reads the values that this run ended with. The same values cause no other push.\n diff: ({ news }) =>\n Effect.succeed(isResolved(news) ? ({ action: \"update\" } as const) : undefined),\n\n read: ({ output }) => Effect.succeed(output),\n\n reconcile: Effect.fn(function* ({ news, output }) {\n const saved = output?.status === \"off\" ? undefined : output;\n const renewed = saved?.redirectUri === news.redirectUri ? yield* renew(saved) : undefined;\n return renewed ?? (yield* newClient(news));\n }),\n\n // A failed delete does not stop a destroy: the removal date of a preview client cleans it up.\n // A destroy can leave a production deployment running, so its client stays.\n delete: ({ output }) =>\n output.deploymentType === \"production\" || output.status === \"off\"\n ? Effect.void\n : Effect.gen(function* () {\n const { status } = yield* send(\n \"The identity hub\",\n output.registrationClientUri,\n Redacted.value(output.registrationAccessToken),\n { method: \"DELETE\" },\n );\n // 401: the client is gone already.\n if (status !== 204 && status !== 401) {\n return yield* new SharedHubClientFailed({\n reason: `The identity hub answered HTTP ${status}`,\n });\n }\n }).pipe(\n Effect.catchTag(\"SharedHubClientFailed\", (error) =>\n Effect.logWarning(\n `Convex.SharedHubClient: the client of ${output.redirectUri} stays on the hub until its removal date: ${error.reason}.`,\n ),\n ),\n ),\n });\n","// The provider collection users merge into a stack:\n// providers: Layer.mergeAll(Cloudflare.providers(), Convex.providers())\nimport * as Provider from \"alchemy/Provider\";\nimport * as Layer from \"effect/Layer\";\nimport { type Credentials, fromConfig } from \"./Credentials.ts\";\nimport { DeploymentProvider, DeploymentResource } from \"./Deployment.ts\";\nimport { HubClient, HubClientProvider } from \"./HubClient.ts\";\nimport { ManagementApiLive } from \"./ManagementApi.ts\";\nimport { Project, ProjectProvider } from \"./Project.ts\";\nimport { SharedHubClient, SharedHubClientProvider } from \"./SharedHubClient.ts\";\n\nexport class Providers extends Provider.ProviderCollection<Providers>()(\"Convex\") {}\n\n/**\n * The Convex resource providers. Pass a credentials layer to override the\n * default lookup (`CONVEX_ACCESS_TOKEN`, then the Convex CLI login, and\n * `CONVEX_DEPLOY_KEY`).\n */\nexport const providers = (credentials: Layer.Layer<Credentials> = fromConfig()) =>\n Layer.effect(\n Providers,\n Provider.collection([Project, DeploymentResource, HubClient, SharedHubClient]),\n ).pipe(\n Layer.provide(\n Layer.mergeAll(\n ProjectProvider(),\n DeploymentProvider(),\n HubClientProvider(),\n SharedHubClientProvider(),\n ),\n ),\n Layer.provide(ManagementApiLive()),\n Layer.provide(credentials),\n Layer.orDie,\n );\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@samebase/alchemy-convex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "The Convex plane for Alchemy v2: projects, deployments, environment variables, and the push of the functions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"alchemy",
|
|
@@ -48,25 +48,26 @@
|
|
|
48
48
|
"@convex-dev/platform": "0.1.11"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@alchemy.run/cloudflare": "2.0.0-beta.
|
|
51
|
+
"@alchemy.run/cloudflare": "2.0.0-beta.85",
|
|
52
|
+
"@alchemy.run/test": "2.0.0-beta.85",
|
|
52
53
|
"@arethetypeswrong/core": "0.18.5",
|
|
53
|
-
"@effect/platform-node": "4.
|
|
54
|
-
"@effect/vitest": "4.
|
|
54
|
+
"@effect/platform-node": "4.1.2",
|
|
55
|
+
"@effect/vitest": "4.1.2",
|
|
55
56
|
"@types/node": "24.19.2",
|
|
56
|
-
"alchemy": "2.0.0-beta.
|
|
57
|
+
"alchemy": "2.0.0-beta.85",
|
|
57
58
|
"convex": "1.48.0",
|
|
58
59
|
"convex-test": "0.0.60",
|
|
59
|
-
"effect": "4.
|
|
60
|
+
"effect": "4.1.2",
|
|
60
61
|
"publint": "0.3.25",
|
|
61
62
|
"typescript": "6.0.3",
|
|
62
63
|
"vite-plus": "1.3.0",
|
|
63
64
|
"vitest": "5.0.5"
|
|
64
65
|
},
|
|
65
66
|
"peerDependencies": {
|
|
66
|
-
"@effect/platform-node": "^4.0
|
|
67
|
-
"alchemy": "2.0.0-beta.
|
|
67
|
+
"@effect/platform-node": "^4.1.0",
|
|
68
|
+
"alchemy": "2.0.0-beta.85",
|
|
68
69
|
"convex": "^1.48.0",
|
|
69
|
-
"effect": "^4.0
|
|
70
|
+
"effect": "^4.1.0"
|
|
70
71
|
},
|
|
71
72
|
"engines": {
|
|
72
73
|
"node": ">=22"
|
package/src/SharedHubClient.ts
CHANGED
|
@@ -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.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
13
|
-
//
|
|
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
|
-
/**
|
|
33
|
-
|
|
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
|
|
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
|
-
|
|
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: `${
|
|
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.
|
|
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.
|
|
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.
|
|
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?.
|
|
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.
|
|
301
|
+
`Convex.SharedHubClient: the client of ${output.redirectUri} stays on the hub until its removal date: ${error.reason}.`,
|
|
268
302
|
),
|
|
269
303
|
),
|
|
270
304
|
),
|