@samebase/alchemy-convex 0.25.1 → 0.27.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 +94 -21
- package/lib/cli.js.map +1 -1
- package/lib/index.d.ts +59 -14
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +134 -22
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/SharedHubClient.ts +225 -58
- package/src/cli.ts +1 -1
package/README.md
CHANGED
|
@@ -313,33 +313,52 @@ call.
|
|
|
313
313
|
- A destroy deletes a preview or development client. It does nothing to a production client,
|
|
314
314
|
because the production deployment can stay.
|
|
315
315
|
|
|
316
|
-
- `Convex.SharedHubClient`:
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
`
|
|
316
|
+
- `Convex.SharedHubClient`:
|
|
317
|
+
`{ app, redirectUri, deploymentType, initialAccessToken?, softwareVersion? }` gets a client of the
|
|
318
|
+
central identity hub, for an app that deploys in its own GitHub Actions run, on any host, and for
|
|
319
|
+
a laptop as a stage of the same stack. `deploymentType` is `production`, `preview`, or
|
|
320
|
+
`development`. A production or preview client needs `permissions: id-token: write` in the
|
|
321
|
+
workflow, and no key and no Samebase account. `app` is at most 64 characters. The origin of an
|
|
322
|
+
https `redirectUri` must serve `/samebase-verification.json` with the numeric GitHub id of the
|
|
323
|
+
repository, `{ "repository": "123" }`, which `gh api repos/<owner>/<name> --jq .id` prints. The
|
|
324
|
+
hub reads it before it registers the client, so the site must be live first. Outputs `status`,
|
|
325
|
+
`issuer`, `clientId`, `clientSecret`, `registrationAccessToken`, `registrationClientUri`,
|
|
326
|
+
`deploymentType`, `redirectUri`, `initialAccessToken`, and `repository`.
|
|
327
|
+
- A production registration also gets the app's initial access token (RFC 7591) and the numeric
|
|
328
|
+
repository id, which the state keeps. A renewal keeps both. They are empty for a preview, for a
|
|
329
|
+
development client, and for the off values.
|
|
330
|
+
- A development client needs `initialAccessToken`, and only a development client takes it. The
|
|
331
|
+
client asks the hub with this token in place of the GitHub OIDC token, so it works on a laptop.
|
|
332
|
+
With an empty token, the client gets the off values, and the log says "deploy to main once for
|
|
333
|
+
Google sign-in on this laptop". See "The development stage" below.
|
|
324
334
|
- Each deploy reads the saved client on the hub with its own token. A client that is gone gets a
|
|
325
|
-
new one, and a client on another `redirectUri` too.
|
|
335
|
+
new one, and a client on another `redirectUri` or of another `deploymentType` too.
|
|
326
336
|
- A new client logs the link to the terms of the shared sign-in service. A refusal logs its
|
|
327
|
-
reason, such as a host that does not serve the file, a block,
|
|
337
|
+
reason, such as a host that does not serve the file, a block, a token of another app, or the
|
|
338
|
+
hourly limit it reached.
|
|
328
339
|
- No failure stops a deploy or a destroy. A failed renewal keeps the saved values. Without saved
|
|
329
340
|
values, `status` is `off`, and `issuer`, `clientId`, and `clientSecret` are the `no-hub` values
|
|
330
|
-
of a checkout without a hub. A run without a GitHub OIDC token, such as on
|
|
331
|
-
same, and logs no warning.
|
|
332
|
-
- `status` is `suspended` when the hub suspended the client. The
|
|
333
|
-
|
|
334
|
-
|
|
341
|
+
of a checkout without a hub. A production or preview run without a GitHub OIDC token, such as on
|
|
342
|
+
a laptop, does the same, and logs no warning.
|
|
343
|
+
- `status` is `suspended` when the hub suspended the client. The state keeps the client and its
|
|
344
|
+
values, so the client works again when the hub lifts the suspension.
|
|
345
|
+
- `Convex.SharedHubClient.variables(client)` gives `issuer`, `clientId`, and `clientSecret` for
|
|
346
|
+
the app's deployment: the client's values when `status` is `on`, and the `no-hub` values when it
|
|
347
|
+
is `off` or `suspended`. Give these to the deployment, not the outputs of the client.
|
|
348
|
+
- `Convex.SharedHubClient.read(id, { stage })` gives the attributes of the client `id` in another
|
|
349
|
+
stage of the same stack, from the state store of the stack. When that stage has no row, it gives
|
|
350
|
+
the off attributes and does not fail. `Convex.SharedHubClient.ref` fails when the row is
|
|
351
|
+
missing, and a laptop before the first deploy to main has no `prod` row.
|
|
352
|
+
- A destroy deletes a preview or development client. It does nothing to a production client,
|
|
353
|
+
because the production deployment can stay.
|
|
335
354
|
- `HUB_URL` replaces `https://auth.samebase.com`, the hub that the run asks at `/hub-clients`.
|
|
336
355
|
|
|
337
|
-
A development deployment has no stack, so the package has a
|
|
338
|
-
of the app, before the first push and at each dev start,
|
|
339
|
-
CLI login. It renews the client that `HUB_CLIENT_ID` names,
|
|
340
|
-
last dev start or use. A deployment without all three
|
|
341
|
-
has, gets a new client, and the command writes
|
|
342
|
-
`HUB_CLIENT_SECRET` to it.
|
|
356
|
+
A development deployment of a team app (`Convex.HubClient`) has no stack, so the package has a
|
|
357
|
+
command for it. Run it in the directory of the app, before the first push and at each dev start,
|
|
358
|
+
with `CONVEX_ACCESS_TOKEN` or the Convex CLI login. It renews the client that `HUB_CLIENT_ID` names,
|
|
359
|
+
so the client expires 14 days after the last dev start or use. A deployment without all three
|
|
360
|
+
variables, or whose client the hub no longer has, gets a new client, and the command writes
|
|
361
|
+
`HUB_ISSUER`, `HUB_CLIENT_ID`, and `HUB_CLIENT_SECRET` to it.
|
|
343
362
|
|
|
344
363
|
```sh
|
|
345
364
|
npx alchemy-convex hub-client --team my-team --app scout --callback-path /auth/hub/callback
|
|
@@ -350,6 +369,60 @@ For a separate API-connection callback, register another client with
|
|
|
350
369
|
`RESOURCE_HUB_CLIENT_ID`, and `RESOURCE_HUB_CLIENT_SECRET`, preserving the sign-in client's
|
|
351
370
|
variables. Each callback remains one exact registered URI.
|
|
352
371
|
|
|
372
|
+
### The development stage
|
|
373
|
+
|
|
374
|
+
A laptop is a stage `dev-<name>` of the app's stack, with its own cloud development deployment. Run
|
|
375
|
+
`alchemy deploy --stage dev-<name>` with `CONVEX_ACCESS_TOKEN` or the Convex CLI login of the app's
|
|
376
|
+
team, before `convex dev`. The stage has the same three rows as a preview:
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
// The stage is dev-<name>. `project` is the project of the deployment that the Convex CLI selects.
|
|
380
|
+
const prod = Convex.SharedHubClient.read("HubClient", { stage: "prod" });
|
|
381
|
+
const deployment =
|
|
382
|
+
yield *
|
|
383
|
+
Convex.Deployment("Deployment", {
|
|
384
|
+
project,
|
|
385
|
+
type: "dev",
|
|
386
|
+
name: stage,
|
|
387
|
+
source: "./convex",
|
|
388
|
+
env: { SAMEBASE_REPOSITORY_ID: prod.repository },
|
|
389
|
+
}).pipe(adopt(true));
|
|
390
|
+
const hub =
|
|
391
|
+
yield *
|
|
392
|
+
Convex.SharedHubClient("HubClient", {
|
|
393
|
+
app: "scout",
|
|
394
|
+
redirectUri: Output.interpolate`${deployment.siteUrl}/auth/hub/callback`,
|
|
395
|
+
deploymentType: "development",
|
|
396
|
+
initialAccessToken: prod.initialAccessToken,
|
|
397
|
+
});
|
|
398
|
+
const variables = Convex.SharedHubClient.variables(hub);
|
|
399
|
+
yield *
|
|
400
|
+
Convex.Deployment("Site", {
|
|
401
|
+
project,
|
|
402
|
+
type: "dev",
|
|
403
|
+
name: stage,
|
|
404
|
+
env: {
|
|
405
|
+
HUB_ISSUER: variables.issuer,
|
|
406
|
+
HUB_CLIENT_ID: variables.clientId,
|
|
407
|
+
HUB_CLIENT_SECRET: variables.clientSecret,
|
|
408
|
+
},
|
|
409
|
+
}).pipe(adopt(true));
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
The imports are `alchemy/AdoptPolicy` for `adopt` and `alchemy/Output` for `Output`.
|
|
413
|
+
|
|
414
|
+
1. `read` gives the initial access token and the repository id from the `HubClient` row of stage
|
|
415
|
+
`prod`. Before the first deploy to main, they are empty.
|
|
416
|
+
2. "Deployment" finds the development deployment by its reference, or creates it. It sets
|
|
417
|
+
`SAMEBASE_REPOSITORY_ID`, which the site serves as `/samebase-verification.json`, and pushes.
|
|
418
|
+
3. "HubClient" asks the hub for a development client on the deployment's site URL with the token.
|
|
419
|
+
Each later run renews it. A development client expires 14 days after its last run or use.
|
|
420
|
+
4. "Site" writes the three variables. It removes only a name that leaves its own `env`, so
|
|
421
|
+
`SAMEBASE_REPOSITORY_ID` stays.
|
|
422
|
+
|
|
423
|
+
Both deployment rows keep the default removal policy `retain`. `alchemy destroy --stage dev-<name>`
|
|
424
|
+
deletes the development client and keeps the deployment.
|
|
425
|
+
|
|
353
426
|
## Pull request comment
|
|
354
427
|
|
|
355
428
|
`alchemy-convex stack-comment` keeps one comment on a pull request. The comment has a card for each
|
package/lib/cli.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.js","names":[],"sources":["../src/StackComment.ts","../src/cli.ts"],"sourcesContent":["// The command `alchemy-convex stack-comment`: one comment on a pull request with the stacks of the\n// repository, read from the state store after the deploys.\n//\n// A stack sees only itself, so a comment that a stack declares cannot show the other stacks of the\n// repository. This command runs as a step after them and reads each stack named `owner/name` or\n// `owner/name/<folder>` that has the stage `pr-<number>`. A stack without that stage has no card. A\n// run replaces the whole body, so the last run shows the newest state.\n//\n// It reads only the fields of the rows below, because Alchemy state holds secrets in clear text.\n//\n// GitHub keeps a fixed set of HTML in a comment and removes `style` and `class`, so every choice of\n// the markup is a fact about the sanitizer or the CSS of github.com:\n// - A table has `display: block` and `width: max-content`, so a `width` on it does nothing. The\n// spacer, a floated image one pixel high in a block of its own, gives every card one width, and\n// it shrinks on a narrow page.\n// - The CSS colors the even rows of a group, so each row has its own `tbody`.\n// - An HTML block ends at a blank line, so a table has none and is not indented.\n// - A bare image links to its raw file, and one link around an image and text underlines the space\n// between them, so the icon and the name are two links to one place.\n// - An image with `height` gets a gray background, so each icon is square and has only `width`.\n// - `align=\"middle\"` is `-webkit-baseline-middle` in Chrome, which is below the text.\n// `align=\"absmiddle\"` is the CSS value `middle`.\nimport { State } from \"alchemy/State\";\nimport * as Effect from \"effect/Effect\";\nimport * as Schema from \"effect/Schema\";\nimport { RUN_RECORD_KEY, runRecord } from \"./RunRecord.ts\";\n\n/** The first text of the comment, which finds it again. */\nconst MARKER = \"<!-- samebase-stacks -->\";\n\n/** GitHub does not proxy this host, so a new icon needs no purge of its image cache. */\nconst ICONS =\n \"https://raw.githubusercontent.com/samebase/samebase/main/packages/alchemy-convex/assets/stack-comment\";\n\nconst GITHUB_API = \"https://api.github.com\";\n\n/** The resources that a card shows. A row of any other type, or without `attr` from an interrupted deploy, is left out. */\nconst Resource = Schema.Union([\n Schema.Struct({\n resourceType: Schema.Literal(\"Convex.Deployment\"),\n attr: Schema.Struct({ name: Schema.String, siteUrl: Schema.String }),\n }),\n Schema.Struct({\n resourceType: Schema.Literal(\"Cloudflare.Worker\"),\n attr: Schema.Struct({ workerName: Schema.String, url: Schema.optional(Schema.String) }),\n }),\n Schema.Struct({\n resourceType: Schema.Literal(\"Cloudflare.R2.Bucket\"),\n attr: Schema.Struct({\n bucketName: Schema.String,\n accountId: Schema.String,\n jurisdiction: Schema.String,\n }),\n }),\n Schema.Struct({\n resourceType: Schema.Literal(\"IdentityHub.SharedClient\"),\n attr: Schema.Struct({ status: Schema.Literals([\"on\", \"off\", \"suspended\"]) }),\n }),\n]);\ntype Resource = typeof Resource.Type;\n\n/** The fields of the run record that a card shows. */\nconst StageOutput = Schema.Struct({\n [RUN_RECORD_KEY]: Schema.Struct({\n headCommit: Schema.String,\n runUrl: Schema.NullOr(Schema.String),\n recordedAt: Schema.String,\n }),\n});\n\nexport interface StackCard {\n readonly name: string;\n readonly stage: string;\n readonly resources: readonly Resource[];\n readonly run: typeof StageOutput.Type | null;\n}\n\nexport class StackCommentError extends Schema.TaggedError<StackCommentError>()(\n \"StackCommentError\",\n { reason: Schema.String },\n) {\n override get message() {\n return `The stack comment cannot be updated: ${this.reason}`;\n }\n}\n\n/** The cards of the stacks of `repository` that have `stage`, in name order. */\nexport const readStackCards = ({ repository, stage }: { repository: string; stage: string }) =>\n Effect.gen(function* () {\n const state = yield* yield* State;\n const names = (yield* state.listStacks()).filter(\n (name) => name === repository || name.startsWith(`${repository}/`),\n );\n const cards: StackCard[] = [];\n for (const name of names) {\n if (!(yield* state.listStages(name)).includes(stage)) {\n continue;\n }\n const request = { stack: name, stage };\n const rows = yield* Effect.forEach(yield* state.list(request), (fqn) =>\n state.get({ ...request, fqn }),\n );\n const output = yield* state.getOutput(request);\n cards.push({\n name,\n stage,\n resources: rows.filter(Schema.is(Resource)),\n run: Schema.is(StageOutput)(output) ? output : null,\n });\n }\n return cards;\n });\n\n/** A stack can create a deployment in one resource and push to it in another: a card shows it once. */\nconst withoutRepeats = (resources: readonly Resource[]) => {\n const deployments = new Set<string>();\n return resources.filter((resource) => {\n if (resource.resourceType !== \"Convex.Deployment\") {\n return true;\n }\n const repeated = deployments.has(resource.attr.name);\n deployments.add(resource.attr.name);\n return !repeated;\n });\n};\n\nconst escapeHtml = (text: string) =>\n text\n .replaceAll(\"&\", \"&\")\n .replaceAll(\"<\", \"<\")\n .replaceAll(\">\", \">\")\n .replaceAll('\"', \""\");\n\nconst link = (href: string, text: string) => `<a href=\"${escapeHtml(href)}\">${text}</a>`;\n\nconst named = (href: string, icon: string, alt: string, name: string) => {\n const image = `<img src=\"${ICONS}/${icon}.svg\" width=\"16\" align=\"absmiddle\" alt=\"${alt}\">`;\n return `${link(href, image)} ${link(href, escapeHtml(name))}`;\n};\n\nconst hostOf = (url: string) => escapeHtml(new URL(url).host);\n\n/** Where a stack serves: its Convex deployment, else its first Worker with a URL. */\nconst addressOf = (resources: readonly Resource[]) => {\n for (const resource of resources) {\n if (resource.resourceType === \"Convex.Deployment\") {\n return resource.attr.siteUrl;\n }\n }\n for (const resource of resources) {\n if (resource.resourceType === \"Cloudflare.Worker\" && resource.attr.url !== undefined) {\n return resource.attr.url;\n }\n }\n return null;\n};\n\nconst resourceRow = (resource: Resource, address: string | null) => {\n switch (resource.resourceType) {\n case \"Convex.Deployment\": {\n const { name } = resource.attr;\n return named(\n `https://dashboard.convex.dev/d/${encodeURIComponent(name)}`,\n \"convex\",\n \"Convex\",\n name,\n );\n }\n case \"Cloudflare.Worker\": {\n const { url, workerName } = resource.attr;\n const row = named(\n `https://dash.cloudflare.com/?to=/:account/workers/services/view/${encodeURIComponent(workerName)}`,\n \"worker\",\n \"Worker\",\n workerName,\n );\n return url === undefined || url === address\n ? row\n : `${row}<br><sub>${link(url, hostOf(url))}</sub>`;\n }\n case \"Cloudflare.R2.Bucket\": {\n const { accountId, bucketName, jurisdiction } = resource.attr;\n return named(\n `https://dash.cloudflare.com/${encodeURIComponent(accountId)}/r2/${encodeURIComponent(jurisdiction)}/buckets/${encodeURIComponent(bucketName)}`,\n \"r2\",\n \"R2 bucket\",\n bucketName,\n );\n }\n case \"IdentityHub.SharedClient\": {\n const { status } = resource.attr;\n return `Google sign-in: ${status === \"off\" ? \"not set up\" : status}`;\n }\n }\n};\n\nconst runLine = ({ run }: StackCard) => {\n if (run === null) {\n return \"\";\n }\n const { headCommit, recordedAt, runUrl } = run[RUN_RECORD_KEY];\n const commit = escapeHtml(headCommit.slice(0, 7));\n const time = `${recordedAt.slice(0, 16).replace(\"T\", \" \")} UTC`;\n return `<br><sub>${runUrl === null ? commit : link(runUrl, commit)} ${escapeHtml(time)}</sub>`;\n};\n\nconst SPACER = `<div><img src=\"${ICONS}/spacer.svg\" width=\"460\" align=\"left\" alt=\"\"></div>`;\n\n/** `alone`: the one card of the repository. A card at the root then has no folder, as on the repository page. */\nconst renderCard = (card: StackCard, repository: string, alone: boolean) => {\n const folder = card.name.slice(repository.length + 1);\n const heading = [\n folder === \"\" && alone ? \"\" : `<b>${escapeHtml(folder || \"/\")}</b> `,\n `<sub>${escapeHtml(card.stage)}</sub>`,\n ].join(\"\");\n const resources = withoutRepeats(card.resources);\n const address = addressOf(resources);\n const addressLine = address === null ? \"\" : `<br>${link(address, hostOf(address))}`;\n const rows = resources\n .map((resource) => `<tbody><tr><td>${resourceRow(resource, address)}</td></tr></tbody>`)\n .join(\"\\n\");\n return `<table>\n<tbody><tr><td>${SPACER}${heading}${addressLine}${runLine(card)}</td></tr></tbody>\n${rows}\n</table>`;\n};\n\nexport const renderStackComment = (cards: readonly StackCard[], repository: string) => `${MARKER}\n${cards.map((card) => renderCard(card, repository, cards.length === 1)).join(\"\\n\\n\")}\n`;\n\nconst Comments = Schema.Array(Schema.Struct({ id: Schema.Number, body: Schema.String }));\nconst SavedComment = Schema.Struct({ html_url: Schema.String });\n\nconst github = (token: string, path: string, init: { method: string; body?: string }) =>\n Effect.tryPromise({\n try: async (): Promise<unknown> => {\n const response = await fetch(`${GITHUB_API}${path}`, {\n ...init,\n headers: {\n Accept: \"application/vnd.github+json\",\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n },\n });\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}`);\n }\n return await response.json();\n },\n catch: (error) =>\n new StackCommentError({\n reason: `GitHub ${init.method} ${path} failed (${error instanceof Error ? error.message : String(error)})`,\n }),\n });\n\nconst decode =\n <S extends Schema.Codec<unknown, unknown>>(schema: S, what: string) =>\n (value: unknown) =>\n Schema.decodeUnknownEffect(schema)(value).pipe(\n Effect.mapError(() => new StackCommentError({ reason: `GitHub gave a bad ${what}` })),\n );\n\nconst findComment = (token: string, path: string) =>\n Effect.gen(function* () {\n for (let page = 1; ; page += 1) {\n const comments = yield* github(token, `${path}?per_page=100&page=${page}`, {\n method: \"GET\",\n }).pipe(Effect.flatMap(decode(Comments, \"comment list\")));\n const comment = comments.find(({ body }) => body.startsWith(MARKER));\n if (comment !== undefined || comments.length < 100) {\n return comment;\n }\n }\n });\n\n/** Replaces the body of the comment of this command, or posts it on the first run, and gives its URL. */\nexport const saveStackComment = (args: {\n readonly token: string;\n readonly repository: string;\n readonly pullRequest: number;\n readonly body: string;\n}) =>\n Effect.gen(function* () {\n const { body, pullRequest, repository, token } = args;\n const comments = `/repos/${repository}/issues/${pullRequest}/comments`;\n const comment = yield* findComment(token, comments);\n const reply = yield* comment === undefined\n ? github(token, comments, { method: \"POST\", body: JSON.stringify({ body }) })\n : github(token, `/repos/${repository}/issues/comments/${comment.id}`, {\n method: \"PATCH\",\n body: JSON.stringify({ body }),\n });\n const saved = yield* decode(SavedComment, \"comment\")(reply);\n return saved.html_url;\n });\n\n/** The step of a workflow: the run record names the repository and the pull request of the run. */\nexport const updateStackComment = Effect.gen(function* () {\n const { pullRequest, repository } = yield* runRecord({ cwd: process.cwd(), env: process.env });\n if (pullRequest === null) {\n return yield* new StackCommentError({ reason: \"this run has no pull request\" });\n }\n const token = process.env[\"GITHUB_TOKEN\"];\n if (token === undefined || token === \"\") {\n return yield* new StackCommentError({ reason: \"GITHUB_TOKEN is not set\" });\n }\n const cards = yield* readStackCards({ repository, stage: `pr-${pullRequest}` });\n return yield* saveStackComment({\n token,\n repository,\n pullRequest,\n body: renderStackComment(cards, repository),\n });\n});\n","#!/usr/bin/env node\n// The commands of the package:\n//\n// alchemy-convex hub-client --team <team> --app <app> --callback-path <path>\n// alchemy-convex stack-comment [--team <team>]\n//\n// hub-client runs in the directory of a Convex app at each dev start, and renews the client of the\n// identity hub of the team that the development deployment of that directory has, or gives it a new\n// one: see registerDevelopmentClient. stack-comment runs as a step of a pull request workflow after\n// the deploys, and keeps one comment with the stacks of the repository: see src/StackComment.ts. It\n// needs GITHUB_TOKEN and, with a team access token, no --team. Both use CONVEX_ACCESS_TOKEN or the\n// Convex CLI login.\nimport { execFileSync } from \"node:child_process\";\nimport { parseArgs } from \"node:util\";\nimport * as NodeServices from \"@effect/platform-node/NodeServices\";\nimport * as Effect from \"effect/Effect\";\nimport * as Layer from \"effect/Layer\";\nimport { fromConfig } from \"./Credentials.ts\";\nimport { registerDevelopmentClient } from \"./HubClient.ts\";\nimport { ManagementApiLive } from \"./ManagementApi.ts\";\nimport { installedCli } from \"./Push.ts\";\nimport { updateStackComment } from \"./StackComment.ts\";\nimport { state } from \"./State.ts\";\n\nconst { positionals, values } = parseArgs({\n allowPositionals: true,\n options: {\n team: { type: \"string\" },\n app: { type: \"string\" },\n \"callback-path\": { type: \"string\" },\n \"env-prefix\": { type: \"string\" },\n },\n});\nconst { team, app, \"callback-path\": callbackPath, \"env-prefix\": envPrefix } = values;\nconst USAGE = `Usage:\n alchemy-convex hub-client --team <team> --app <app> --callback-path <path> [--env-prefix HUB]\n alchemy-convex stack-comment [--team <team>]`;\n\nconst hubClient = (hub: { team: string; app: string; callbackPath: string; envPrefix?: string }) =>\n Effect.gen(function* () {\n const [command, ...prefix] = yield* installedCli(process.cwd());\n const callbackUrl = yield* registerDevelopmentClient({\n ...hub,\n convex: (args, input) =>\n execFileSync(command, [...prefix, ...args], {\n encoding: \"utf8\",\n input,\n stdio: [\"pipe\", \"pipe\", \"inherit\"],\n }).trim(),\n });\n console.log(\n callbackUrl === undefined\n ? \"Hub client renewed.\"\n : `Hub client registered for ${callbackUrl}.`,\n );\n }).pipe(Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))));\n\nconst stackComment = (options: { team?: string }) =>\n updateStackComment.pipe(\n Effect.tap((url) => Effect.sync(() => console.log(url))),\n Effect.provide(state(options).pipe(Layer.provide(NodeServices.layer))),\n );\n\nconst subcommand = positionals.join(\" \");\nconst program: Effect.Effect<unknown, unknown> | undefined =\n subcommand === \"stack-comment\"\n ? stackComment(team === undefined ? {} : { team })\n : subcommand === \"hub-client\" &&\n team !== undefined &&\n app !== undefined &&\n callbackPath !== undefined\n ? hubClient({ team, app, callbackPath, ...(envPrefix === undefined ? {} : { envPrefix }) })\n : undefined;\nif (program === undefined) {\n console.error(USAGE);\n process.exit(1);\n}\n\nawait Effect.runPromise(program).catch((error: unknown) => {\n console.error(error instanceof Error ? error.message : String(error));\n process.exit(1);\n});\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"cli.js","names":[],"sources":["../src/StackComment.ts","../src/cli.ts"],"sourcesContent":["// The command `alchemy-convex stack-comment`: one comment on a pull request with the stacks of the\n// repository, read from the state store after the deploys.\n//\n// A stack sees only itself, so a comment that a stack declares cannot show the other stacks of the\n// repository. This command runs as a step after them and reads each stack named `owner/name` or\n// `owner/name/<folder>` that has the stage `pr-<number>`. A stack without that stage has no card. A\n// run replaces the whole body, so the last run shows the newest state.\n//\n// It reads only the fields of the rows below, because Alchemy state holds secrets in clear text.\n//\n// GitHub keeps a fixed set of HTML in a comment and removes `style` and `class`, so every choice of\n// the markup is a fact about the sanitizer or the CSS of github.com:\n// - A table has `display: block` and `width: max-content`, so a `width` on it does nothing. The\n// spacer, a floated image one pixel high in a block of its own, gives every card one width, and\n// it shrinks on a narrow page.\n// - The CSS colors the even rows of a group, so each row has its own `tbody`.\n// - An HTML block ends at a blank line, so a table has none and is not indented.\n// - A bare image links to its raw file, and one link around an image and text underlines the space\n// between them, so the icon and the name are two links to one place.\n// - An image with `height` gets a gray background, so each icon is square and has only `width`.\n// - `align=\"middle\"` is `-webkit-baseline-middle` in Chrome, which is below the text.\n// `align=\"absmiddle\"` is the CSS value `middle`.\nimport { State } from \"alchemy/State\";\nimport * as Effect from \"effect/Effect\";\nimport * as Schema from \"effect/Schema\";\nimport { RUN_RECORD_KEY, runRecord } from \"./RunRecord.ts\";\n\n/** The first text of the comment, which finds it again. */\nconst MARKER = \"<!-- samebase-stacks -->\";\n\n/** GitHub does not proxy this host, so a new icon needs no purge of its image cache. */\nconst ICONS =\n \"https://raw.githubusercontent.com/samebase/samebase/main/packages/alchemy-convex/assets/stack-comment\";\n\nconst GITHUB_API = \"https://api.github.com\";\n\n/** The resources that a card shows. A row of any other type, or without `attr` from an interrupted deploy, is left out. */\nconst Resource = Schema.Union([\n Schema.Struct({\n resourceType: Schema.Literal(\"Convex.Deployment\"),\n attr: Schema.Struct({ name: Schema.String, siteUrl: Schema.String }),\n }),\n Schema.Struct({\n resourceType: Schema.Literal(\"Cloudflare.Worker\"),\n attr: Schema.Struct({ workerName: Schema.String, url: Schema.optional(Schema.String) }),\n }),\n Schema.Struct({\n resourceType: Schema.Literal(\"Cloudflare.R2.Bucket\"),\n attr: Schema.Struct({\n bucketName: Schema.String,\n accountId: Schema.String,\n jurisdiction: Schema.String,\n }),\n }),\n Schema.Struct({\n resourceType: Schema.Literal(\"IdentityHub.SharedClient\"),\n attr: Schema.Struct({ status: Schema.Literals([\"on\", \"off\", \"suspended\"]) }),\n }),\n]);\ntype Resource = typeof Resource.Type;\n\n/** The fields of the run record that a card shows. */\nconst StageOutput = Schema.Struct({\n [RUN_RECORD_KEY]: Schema.Struct({\n headCommit: Schema.String,\n runUrl: Schema.NullOr(Schema.String),\n recordedAt: Schema.String,\n }),\n});\n\nexport interface StackCard {\n readonly name: string;\n readonly stage: string;\n readonly resources: readonly Resource[];\n readonly run: typeof StageOutput.Type | null;\n}\n\nexport class StackCommentError extends Schema.TaggedError<StackCommentError>()(\n \"StackCommentError\",\n { reason: Schema.String },\n) {\n override get message() {\n return `The stack comment cannot be updated: ${this.reason}`;\n }\n}\n\n/** The cards of the stacks of `repository` that have `stage`, in name order. */\nexport const readStackCards = ({ repository, stage }: { repository: string; stage: string }) =>\n Effect.gen(function* () {\n const state = yield* yield* State;\n const names = (yield* state.listStacks()).filter(\n (name) => name === repository || name.startsWith(`${repository}/`),\n );\n const cards: StackCard[] = [];\n for (const name of names) {\n if (!(yield* state.listStages(name)).includes(stage)) {\n continue;\n }\n const request = { stack: name, stage };\n const rows = yield* Effect.forEach(yield* state.list(request), (fqn) =>\n state.get({ ...request, fqn }),\n );\n const output = yield* state.getOutput(request);\n cards.push({\n name,\n stage,\n resources: rows.filter(Schema.is(Resource)),\n run: Schema.is(StageOutput)(output) ? output : null,\n });\n }\n return cards;\n });\n\n/** A stack can create a deployment in one resource and push to it in another: a card shows it once. */\nconst withoutRepeats = (resources: readonly Resource[]) => {\n const deployments = new Set<string>();\n return resources.filter((resource) => {\n if (resource.resourceType !== \"Convex.Deployment\") {\n return true;\n }\n const repeated = deployments.has(resource.attr.name);\n deployments.add(resource.attr.name);\n return !repeated;\n });\n};\n\nconst escapeHtml = (text: string) =>\n text\n .replaceAll(\"&\", \"&\")\n .replaceAll(\"<\", \"<\")\n .replaceAll(\">\", \">\")\n .replaceAll('\"', \""\");\n\nconst link = (href: string, text: string) => `<a href=\"${escapeHtml(href)}\">${text}</a>`;\n\nconst named = (href: string, icon: string, alt: string, name: string) => {\n const image = `<img src=\"${ICONS}/${icon}.svg\" width=\"16\" align=\"absmiddle\" alt=\"${alt}\">`;\n return `${link(href, image)} ${link(href, escapeHtml(name))}`;\n};\n\nconst hostOf = (url: string) => escapeHtml(new URL(url).host);\n\n/** Where a stack serves: its Convex deployment, else its first Worker with a URL. */\nconst addressOf = (resources: readonly Resource[]) => {\n for (const resource of resources) {\n if (resource.resourceType === \"Convex.Deployment\") {\n return resource.attr.siteUrl;\n }\n }\n for (const resource of resources) {\n if (resource.resourceType === \"Cloudflare.Worker\" && resource.attr.url !== undefined) {\n return resource.attr.url;\n }\n }\n return null;\n};\n\nconst resourceRow = (resource: Resource, address: string | null) => {\n switch (resource.resourceType) {\n case \"Convex.Deployment\": {\n const { name } = resource.attr;\n return named(\n `https://dashboard.convex.dev/d/${encodeURIComponent(name)}`,\n \"convex\",\n \"Convex\",\n name,\n );\n }\n case \"Cloudflare.Worker\": {\n const { url, workerName } = resource.attr;\n const row = named(\n `https://dash.cloudflare.com/?to=/:account/workers/services/view/${encodeURIComponent(workerName)}`,\n \"worker\",\n \"Worker\",\n workerName,\n );\n return url === undefined || url === address\n ? row\n : `${row}<br><sub>${link(url, hostOf(url))}</sub>`;\n }\n case \"Cloudflare.R2.Bucket\": {\n const { accountId, bucketName, jurisdiction } = resource.attr;\n return named(\n `https://dash.cloudflare.com/${encodeURIComponent(accountId)}/r2/${encodeURIComponent(jurisdiction)}/buckets/${encodeURIComponent(bucketName)}`,\n \"r2\",\n \"R2 bucket\",\n bucketName,\n );\n }\n case \"IdentityHub.SharedClient\": {\n const { status } = resource.attr;\n return `Google sign-in: ${status === \"off\" ? \"not set up\" : status}`;\n }\n }\n};\n\nconst runLine = ({ run }: StackCard) => {\n if (run === null) {\n return \"\";\n }\n const { headCommit, recordedAt, runUrl } = run[RUN_RECORD_KEY];\n const commit = escapeHtml(headCommit.slice(0, 7));\n const time = `${recordedAt.slice(0, 16).replace(\"T\", \" \")} UTC`;\n return `<br><sub>${runUrl === null ? commit : link(runUrl, commit)} ${escapeHtml(time)}</sub>`;\n};\n\nconst SPACER = `<div><img src=\"${ICONS}/spacer.svg\" width=\"460\" align=\"left\" alt=\"\"></div>`;\n\n/** `alone`: the one card of the repository. A card at the root then has no folder, as on the repository page. */\nconst renderCard = (card: StackCard, repository: string, alone: boolean) => {\n const folder = card.name.slice(repository.length + 1);\n const heading = [\n folder === \"\" && alone ? \"\" : `<b>${escapeHtml(folder || \"/\")}</b> `,\n `<sub>${escapeHtml(card.stage)}</sub>`,\n ].join(\"\");\n const resources = withoutRepeats(card.resources);\n const address = addressOf(resources);\n const addressLine = address === null ? \"\" : `<br>${link(address, hostOf(address))}`;\n const rows = resources\n .map((resource) => `<tbody><tr><td>${resourceRow(resource, address)}</td></tr></tbody>`)\n .join(\"\\n\");\n return `<table>\n<tbody><tr><td>${SPACER}${heading}${addressLine}${runLine(card)}</td></tr></tbody>\n${rows}\n</table>`;\n};\n\nexport const renderStackComment = (cards: readonly StackCard[], repository: string) => `${MARKER}\n${cards.map((card) => renderCard(card, repository, cards.length === 1)).join(\"\\n\\n\")}\n`;\n\nconst Comments = Schema.Array(Schema.Struct({ id: Schema.Number, body: Schema.String }));\nconst SavedComment = Schema.Struct({ html_url: Schema.String });\n\nconst github = (token: string, path: string, init: { method: string; body?: string }) =>\n Effect.tryPromise({\n try: async (): Promise<unknown> => {\n const response = await fetch(`${GITHUB_API}${path}`, {\n ...init,\n headers: {\n Accept: \"application/vnd.github+json\",\n Authorization: `Bearer ${token}`,\n \"Content-Type\": \"application/json\",\n \"X-GitHub-Api-Version\": \"2022-11-28\",\n },\n });\n if (!response.ok) {\n throw new Error(`HTTP ${response.status}`);\n }\n return await response.json();\n },\n catch: (error) =>\n new StackCommentError({\n reason: `GitHub ${init.method} ${path} failed (${error instanceof Error ? error.message : String(error)})`,\n }),\n });\n\nconst decode =\n <S extends Schema.Codec<unknown, unknown>>(schema: S, what: string) =>\n (value: unknown) =>\n Schema.decodeUnknownEffect(schema)(value).pipe(\n Effect.mapError(() => new StackCommentError({ reason: `GitHub gave a bad ${what}` })),\n );\n\nconst findComment = (token: string, path: string) =>\n Effect.gen(function* () {\n for (let page = 1; ; page += 1) {\n const comments = yield* github(token, `${path}?per_page=100&page=${page}`, {\n method: \"GET\",\n }).pipe(Effect.flatMap(decode(Comments, \"comment list\")));\n const comment = comments.find(({ body }) => body.startsWith(MARKER));\n if (comment !== undefined || comments.length < 100) {\n return comment;\n }\n }\n });\n\n/** Replaces the body of the comment of this command, or posts it on the first run, and gives its URL. */\nexport const saveStackComment = (args: {\n readonly token: string;\n readonly repository: string;\n readonly pullRequest: number;\n readonly body: string;\n}) =>\n Effect.gen(function* () {\n const { body, pullRequest, repository, token } = args;\n const comments = `/repos/${repository}/issues/${pullRequest}/comments`;\n const comment = yield* findComment(token, comments);\n const reply = yield* comment === undefined\n ? github(token, comments, { method: \"POST\", body: JSON.stringify({ body }) })\n : github(token, `/repos/${repository}/issues/comments/${comment.id}`, {\n method: \"PATCH\",\n body: JSON.stringify({ body }),\n });\n const saved = yield* decode(SavedComment, \"comment\")(reply);\n return saved.html_url;\n });\n\n/** The step of a workflow: the run record names the repository and the pull request of the run. */\nexport const updateStackComment = Effect.gen(function* () {\n const { pullRequest, repository } = yield* runRecord({ cwd: process.cwd(), env: process.env });\n if (pullRequest === null) {\n return yield* new StackCommentError({ reason: \"this run has no pull request\" });\n }\n const token = process.env[\"GITHUB_TOKEN\"];\n if (token === undefined || token === \"\") {\n return yield* new StackCommentError({ reason: \"GITHUB_TOKEN is not set\" });\n }\n const cards = yield* readStackCards({ repository, stage: `pr-${pullRequest}` });\n return yield* saveStackComment({\n token,\n repository,\n pullRequest,\n body: renderStackComment(cards, repository),\n });\n});\n","#!/usr/bin/env node\n// The commands of the package:\n//\n// alchemy-convex hub-client --team <team> --app <app> --callback-path <path> [--env-prefix HUB]\n// alchemy-convex stack-comment [--team <team>]\n//\n// hub-client runs in the directory of a Convex app at each dev start, and renews the client of the\n// identity hub of the team that the development deployment of that directory has, or gives it a new\n// one: see registerDevelopmentClient. stack-comment runs as a step of a pull request workflow after\n// the deploys, and keeps one comment with the stacks of the repository: see src/StackComment.ts. It\n// needs GITHUB_TOKEN and, with a team access token, no --team. Both use CONVEX_ACCESS_TOKEN or the\n// Convex CLI login.\nimport { execFileSync } from \"node:child_process\";\nimport { parseArgs } from \"node:util\";\nimport * as NodeServices from \"@effect/platform-node/NodeServices\";\nimport * as Effect from \"effect/Effect\";\nimport * as Layer from \"effect/Layer\";\nimport { fromConfig } from \"./Credentials.ts\";\nimport { registerDevelopmentClient } from \"./HubClient.ts\";\nimport { ManagementApiLive } from \"./ManagementApi.ts\";\nimport { installedCli } from \"./Push.ts\";\nimport { updateStackComment } from \"./StackComment.ts\";\nimport { state } from \"./State.ts\";\n\nconst { positionals, values } = parseArgs({\n allowPositionals: true,\n options: {\n team: { type: \"string\" },\n app: { type: \"string\" },\n \"callback-path\": { type: \"string\" },\n \"env-prefix\": { type: \"string\" },\n },\n});\nconst { team, app, \"callback-path\": callbackPath, \"env-prefix\": envPrefix } = values;\nconst USAGE = `Usage:\n alchemy-convex hub-client --team <team> --app <app> --callback-path <path> [--env-prefix HUB]\n alchemy-convex stack-comment [--team <team>]`;\n\nconst hubClient = (hub: { team: string; app: string; callbackPath: string; envPrefix?: string }) =>\n Effect.gen(function* () {\n const [command, ...prefix] = yield* installedCli(process.cwd());\n const callbackUrl = yield* registerDevelopmentClient({\n ...hub,\n convex: (args, input) =>\n execFileSync(command, [...prefix, ...args], {\n encoding: \"utf8\",\n input,\n stdio: [\"pipe\", \"pipe\", \"inherit\"],\n }).trim(),\n });\n console.log(\n callbackUrl === undefined\n ? \"Hub client renewed.\"\n : `Hub client registered for ${callbackUrl}.`,\n );\n }).pipe(Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))));\n\nconst stackComment = (options: { team?: string }) =>\n updateStackComment.pipe(\n Effect.tap((url) => Effect.sync(() => console.log(url))),\n Effect.provide(state(options).pipe(Layer.provide(NodeServices.layer))),\n );\n\nconst subcommand = positionals.join(\" \");\nconst program: Effect.Effect<unknown, unknown> | undefined =\n subcommand === \"stack-comment\"\n ? stackComment(team === undefined ? {} : { team })\n : subcommand === \"hub-client\" &&\n team !== undefined &&\n app !== undefined &&\n callbackPath !== undefined\n ? hubClient({ team, app, callbackPath, ...(envPrefix === undefined ? {} : { envPrefix }) })\n : undefined;\nif (program === undefined) {\n console.error(USAGE);\n process.exit(1);\n}\n\nawait Effect.runPromise(program).catch((error: unknown) => {\n console.error(error instanceof Error ? error.message : String(error));\n process.exit(1);\n});\n"],"mappings":"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"}
|
package/lib/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
|
|
|
10
10
|
import { createDeploymentClient, createManagementClient } from "@convex-dev/platform";
|
|
11
11
|
import "effect/Scope";
|
|
12
12
|
import { State, StateStoreError } from "alchemy/State";
|
|
13
|
+
import * as Output from "alchemy/Output";
|
|
13
14
|
import { NamespaceNode } from "alchemy/Namespace";
|
|
14
15
|
import { InputProps } from "alchemy/Input";
|
|
15
16
|
import { components } from "@convex-dev/platform/managementApi";
|
|
@@ -502,36 +503,80 @@ export declare const runRecord: (where: RunEnvironment) => Effect.Effect<RunReco
|
|
|
502
503
|
export declare const withRunRecord: (output: unknown, record: RunRecord) => object | undefined;
|
|
503
504
|
//#endregion
|
|
504
505
|
//#region src/SharedHubClient.d.ts
|
|
505
|
-
interface
|
|
506
|
+
interface CommonProps {
|
|
506
507
|
/** The stable name of the app, set once in the stack. */
|
|
507
508
|
readonly app: string;
|
|
508
509
|
/**
|
|
509
510
|
* The return address of the hub's sign-ins, such as `${site.siteUrl}/auth/hub/callback`: an https
|
|
510
511
|
* 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
|
+
* repository, `{ "repository": "123" }`. It can come from a resource that this run creates. A
|
|
513
|
+
* development client can also have http on a loopback host, which needs no file.
|
|
512
514
|
*/
|
|
513
515
|
readonly redirectUri: string;
|
|
514
|
-
readonly deploymentType: "production" | "preview";
|
|
515
516
|
/** Recorded on the hub's client, such as the commit. The hub records "unknown" without it. */
|
|
516
517
|
readonly softwareVersion?: string;
|
|
517
518
|
}
|
|
518
|
-
|
|
519
|
+
/**
|
|
520
|
+
* A production or preview client asks with the GitHub OIDC token of the run. A development client
|
|
521
|
+
* asks with the app's initial access token, which `SharedHubClient.read` gives from stage `prod`.
|
|
522
|
+
*/
|
|
523
|
+
type SharedHubClientProps = (CommonProps & {
|
|
524
|
+
readonly deploymentType: "production" | "preview";
|
|
525
|
+
readonly initialAccessToken?: never;
|
|
526
|
+
}) | (CommonProps & {
|
|
527
|
+
readonly deploymentType: "development";
|
|
528
|
+
/** Empty before the first production registration: the client gets the off values. */
|
|
529
|
+
readonly initialAccessToken: Redacted.Redacted<string>;
|
|
530
|
+
});
|
|
531
|
+
declare const Attributes: Schema.Struct<{
|
|
519
532
|
/** `off`: no client, and the values below are the off values. */
|
|
520
|
-
readonly status: "on"
|
|
533
|
+
readonly status: Schema.Literals<readonly ["on", "off", "suspended"]>;
|
|
521
534
|
/** The issuer of the hub, which the deployment names to sign in. */
|
|
522
|
-
readonly issuer:
|
|
523
|
-
readonly clientId:
|
|
535
|
+
readonly issuer: Schema.String;
|
|
536
|
+
readonly clientId: Schema.String;
|
|
524
537
|
/** Returned only at registration. */
|
|
525
|
-
readonly clientSecret:
|
|
538
|
+
readonly clientSecret: Schema.Redacted<Schema.String>;
|
|
526
539
|
/** Returned only at registration. It reads and deletes this client on the hub, and nothing else. */
|
|
527
|
-
readonly registrationAccessToken:
|
|
528
|
-
readonly registrationClientUri:
|
|
529
|
-
readonly deploymentType: "production"
|
|
540
|
+
readonly registrationAccessToken: Schema.Redacted<Schema.String>;
|
|
541
|
+
readonly registrationClientUri: Schema.String;
|
|
542
|
+
readonly deploymentType: Schema.Literals<readonly ["production", "preview", "development"]>;
|
|
530
543
|
/** The return address that the client serves. Empty for the off values. */
|
|
531
|
-
readonly redirectUri:
|
|
532
|
-
|
|
544
|
+
readonly redirectUri: Schema.String;
|
|
545
|
+
/**
|
|
546
|
+
* Returned only at a production registration (RFC 7591 section 1.2). It registers development
|
|
547
|
+
* clients of this app. Empty for a preview, for a development client, and for the off values.
|
|
548
|
+
*/
|
|
549
|
+
readonly initialAccessToken: Schema.Redacted<Schema.String>;
|
|
550
|
+
/** The numeric GitHub id of the repository, with the initial access token. Empty without it. */
|
|
551
|
+
readonly repository: Schema.String;
|
|
552
|
+
}>;
|
|
553
|
+
/**
|
|
554
|
+
* The attributes keep the real values of a suspended client, so that an unblock needs no new client.
|
|
555
|
+
* `SharedHubClient.variables` gives the values that the app gets.
|
|
556
|
+
*/
|
|
557
|
+
interface SharedHubClientAttributes extends Schema.Schema.Type<typeof Attributes> {}
|
|
533
558
|
export type SharedHubClient = Resource<"IdentityHub.SharedClient", SharedHubClientProps, SharedHubClientAttributes, never, Providers>;
|
|
534
|
-
export declare const SharedHubClient: import("alchemy").
|
|
559
|
+
export declare const SharedHubClient: import("alchemy").ResourceClassWithMethods<SharedHubClient, {
|
|
560
|
+
readonly read: (id: string, options: {
|
|
561
|
+
readonly stage: string;
|
|
562
|
+
}) => Output.ObjectExpr<{
|
|
563
|
+
readonly status: "on" | "off" | "suspended";
|
|
564
|
+
readonly issuer: string;
|
|
565
|
+
readonly clientId: string;
|
|
566
|
+
readonly clientSecret: Redacted.Redacted<string>;
|
|
567
|
+
readonly registrationAccessToken: Redacted.Redacted<string>;
|
|
568
|
+
readonly registrationClientUri: string;
|
|
569
|
+
readonly deploymentType: "preview" | "production" | "development";
|
|
570
|
+
readonly redirectUri: string;
|
|
571
|
+
readonly initialAccessToken: Redacted.Redacted<string>;
|
|
572
|
+
readonly repository: string;
|
|
573
|
+
}, Stack | State>;
|
|
574
|
+
readonly variables: (client: SharedHubClient) => Output.ObjectExpr<{
|
|
575
|
+
issuer: string;
|
|
576
|
+
clientId: string;
|
|
577
|
+
clientSecret: Redacted.Redacted<string>;
|
|
578
|
+
}, never>;
|
|
579
|
+
}>;
|
|
535
580
|
//#endregion
|
|
536
581
|
//#region src/State.d.ts
|
|
537
582
|
/** The name of the state project in each team. */
|
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":"
|
|
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
|
@@ -9,6 +9,8 @@ import * as Redacted from "effect/Redacted";
|
|
|
9
9
|
import { Resource } from "alchemy";
|
|
10
10
|
import { isResolved } from "alchemy/Diff";
|
|
11
11
|
import * as Provider from "alchemy/Provider";
|
|
12
|
+
import { State } from "alchemy/State";
|
|
13
|
+
import * as Output from "alchemy/Output";
|
|
12
14
|
//#region src/Component.ts
|
|
13
15
|
/**
|
|
14
16
|
* Associate a native namespace with an installed component. `component` is the
|
|
@@ -37,7 +39,70 @@ const bindComponent = (options) => Effect.gen(function* () {
|
|
|
37
39
|
//#region src/SharedHubClient.ts
|
|
38
40
|
/** A hung service must not hold a deploy: the time limit of one request. */
|
|
39
41
|
const REQUEST_TIMEOUT_MS = 3e4;
|
|
40
|
-
|
|
42
|
+
/** The variables of a deployment without a hub client, as the starter writes them. */
|
|
43
|
+
const OFF_VALUES = {
|
|
44
|
+
issuer: "http://127.0.0.1:1",
|
|
45
|
+
clientId: "no-hub",
|
|
46
|
+
clientSecret: "no-hub"
|
|
47
|
+
};
|
|
48
|
+
const DeploymentType = Schema.Literals([
|
|
49
|
+
"production",
|
|
50
|
+
"preview",
|
|
51
|
+
"development"
|
|
52
|
+
]);
|
|
53
|
+
const Attributes = Schema.Struct({
|
|
54
|
+
/** `off`: no client, and the values below are the off values. */
|
|
55
|
+
status: Schema.Literals([
|
|
56
|
+
"on",
|
|
57
|
+
"off",
|
|
58
|
+
"suspended"
|
|
59
|
+
]),
|
|
60
|
+
/** The issuer of the hub, which the deployment names to sign in. */
|
|
61
|
+
issuer: Schema.String,
|
|
62
|
+
clientId: Schema.String,
|
|
63
|
+
/** Returned only at registration. */
|
|
64
|
+
clientSecret: Schema.Redacted(Schema.String),
|
|
65
|
+
/** Returned only at registration. It reads and deletes this client on the hub, and nothing else. */
|
|
66
|
+
registrationAccessToken: Schema.Redacted(Schema.String),
|
|
67
|
+
registrationClientUri: Schema.String,
|
|
68
|
+
deploymentType: DeploymentType,
|
|
69
|
+
/** The return address that the client serves. Empty for the off values. */
|
|
70
|
+
redirectUri: Schema.String,
|
|
71
|
+
/**
|
|
72
|
+
* Returned only at a production registration (RFC 7591 section 1.2). It registers development
|
|
73
|
+
* clients of this app. Empty for a preview, for a development client, and for the off values.
|
|
74
|
+
*/
|
|
75
|
+
initialAccessToken: Schema.Redacted(Schema.String),
|
|
76
|
+
/** The numeric GitHub id of the repository, with the initial access token. Empty without it. */
|
|
77
|
+
repository: Schema.String
|
|
78
|
+
});
|
|
79
|
+
/**
|
|
80
|
+
* The attributes as the state saves them. A row of 0.25 and earlier has no token fields, and they
|
|
81
|
+
* stay empty until the next production registration.
|
|
82
|
+
*/
|
|
83
|
+
const SavedAttributes = Schema.Struct({
|
|
84
|
+
...Attributes.fields,
|
|
85
|
+
initialAccessToken: Schema.optionalKey(Schema.Redacted(Schema.String)),
|
|
86
|
+
repository: Schema.optionalKey(Schema.String)
|
|
87
|
+
});
|
|
88
|
+
const savedAttributes = (value) => Schema.decodeUnknownEffect(SavedAttributes)(value).pipe(Effect.map(({ initialAccessToken = Redacted.make(""), repository = "", ...attributes }) => ({
|
|
89
|
+
...attributes,
|
|
90
|
+
initialAccessToken,
|
|
91
|
+
repository
|
|
92
|
+
})), Effect.catchCause(() => Effect.die(new SharedHubClientFailed({ reason: "the state holds attributes of Convex.SharedHubClient that it cannot read" }))));
|
|
93
|
+
/** The attributes of no client. */
|
|
94
|
+
const offAttributes = (deploymentType) => ({
|
|
95
|
+
status: "off",
|
|
96
|
+
issuer: OFF_VALUES.issuer,
|
|
97
|
+
clientId: OFF_VALUES.clientId,
|
|
98
|
+
clientSecret: Redacted.make(OFF_VALUES.clientSecret),
|
|
99
|
+
registrationAccessToken: Redacted.make(""),
|
|
100
|
+
registrationClientUri: "",
|
|
101
|
+
deploymentType,
|
|
102
|
+
redirectUri: "",
|
|
103
|
+
initialAccessToken: Redacted.make(""),
|
|
104
|
+
repository: ""
|
|
105
|
+
});
|
|
41
106
|
/** One request gave no usable answer. The reason holds no secret. */
|
|
42
107
|
var SharedHubClientFailed = class extends Schema.TaggedError()("SharedHubClientFailed", { reason: Schema.String }) {};
|
|
43
108
|
const OidcToken = Schema.Struct({ value: Schema.NonEmptyString });
|
|
@@ -50,7 +115,10 @@ const NewClient = Schema.Struct({
|
|
|
50
115
|
registration_client_uri: Schema.NonEmptyString,
|
|
51
116
|
redirect_uri: Schema.NonEmptyString,
|
|
52
117
|
/** The terms of the shared sign-in service, which the log shows once, for the new client. */
|
|
53
|
-
terms: Schema.NonEmptyString
|
|
118
|
+
terms: Schema.NonEmptyString,
|
|
119
|
+
/** Only in the answer to a production registration with the GitHub token. */
|
|
120
|
+
initial_access_token: Schema.optionalKey(Schema.NonEmptyString),
|
|
121
|
+
repository: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^\d+$/)))
|
|
54
122
|
});
|
|
55
123
|
/** The other answers of `POST /hub-clients`, by status. */
|
|
56
124
|
const REFUSALS = {
|
|
@@ -73,7 +141,8 @@ const NOT_ALLOWED = /* @__PURE__ */ new Map([
|
|
|
73
141
|
["host_unreachable", "the identity hub could not reach the host of the return address"],
|
|
74
142
|
["verification_file_missing", "the host of the return address does not serve /samebase-verification.json"],
|
|
75
143
|
["verification_file_invalid", "/samebase-verification.json on the host holds no repository id"],
|
|
76
|
-
["repository_mismatch", "/samebase-verification.json on the host names another repository"]
|
|
144
|
+
["repository_mismatch", "/samebase-verification.json on the host names another repository"],
|
|
145
|
+
["app_mismatch", "the initial access token belongs to another app on the identity hub"]
|
|
77
146
|
]);
|
|
78
147
|
/** The windows of a 429, by the name that the answer gives. */
|
|
79
148
|
const RATE_LIMITED = /* @__PURE__ */ new Map([
|
|
@@ -112,9 +181,9 @@ const readOidcToken = (requestUrl, requestToken) => Effect.gen(function* () {
|
|
|
112
181
|
if (status !== 200) return yield* new SharedHubClientFailed({ reason: `GitHub gave no OIDC token (HTTP ${status})` });
|
|
113
182
|
return (yield* decode(OidcToken, "GitHub")(body)).value;
|
|
114
183
|
});
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const { body, status } = yield* send("The identity hub", `${process.env["HUB_URL"] ?? "https://auth.samebase.com"}/hub-clients`,
|
|
184
|
+
/** `POST /hub-clients` with `bearer`: the GitHub OIDC token, or the app's initial access token. */
|
|
185
|
+
const requestNewClient = (props, bearer) => Effect.gen(function* () {
|
|
186
|
+
const { body, status } = yield* send("The identity hub", `${process.env["HUB_URL"] ?? "https://auth.samebase.com"}/hub-clients`, bearer, {
|
|
118
187
|
method: "POST",
|
|
119
188
|
body: JSON.stringify({
|
|
120
189
|
app: props.app,
|
|
@@ -122,7 +191,7 @@ const requestNewClient = (props, requestUrl, requestToken) => Effect.gen(functio
|
|
|
122
191
|
softwareVersion: props.softwareVersion ?? "unknown"
|
|
123
192
|
})
|
|
124
193
|
});
|
|
125
|
-
if (status !== 201) return yield* new SharedHubClientFailed({ reason: `${refusalOf(status, body)} (HTTP ${status})` });
|
|
194
|
+
if (status !== 201) return yield* new SharedHubClientFailed({ reason: `${status === 401 && props.deploymentType === "development" ? "the identity hub refused the initial access token" : refusalOf(status, body)} (HTTP ${status})` });
|
|
126
195
|
const client = yield* decode(NewClient, "The identity hub")(body);
|
|
127
196
|
yield* Effect.logInfo(`Convex.SharedHubClient: ${props.app} on ${client.redirect_uri} has a new client of the identity hub. Its use is under ${client.terms}.`);
|
|
128
197
|
return {
|
|
@@ -133,26 +202,24 @@ const requestNewClient = (props, requestUrl, requestToken) => Effect.gen(functio
|
|
|
133
202
|
registrationAccessToken: Redacted.make(client.registration_access_token),
|
|
134
203
|
registrationClientUri: client.registration_client_uri,
|
|
135
204
|
deploymentType: props.deploymentType,
|
|
136
|
-
redirectUri: client.redirect_uri
|
|
205
|
+
redirectUri: client.redirect_uri,
|
|
206
|
+
initialAccessToken: Redacted.make(client.initial_access_token ?? ""),
|
|
207
|
+
repository: client.repository ?? ""
|
|
137
208
|
};
|
|
138
209
|
});
|
|
139
|
-
/** A new client, or
|
|
210
|
+
/** A new client, or the off values. */
|
|
140
211
|
const newClient = (props) => {
|
|
141
212
|
const label = `Convex.SharedHubClient: ${props.app} on ${props.redirectUri} deploys without the hub`;
|
|
142
|
-
const off =
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
registrationClientUri: "",
|
|
149
|
-
deploymentType: props.deploymentType,
|
|
150
|
-
redirectUri: ""
|
|
151
|
-
};
|
|
213
|
+
const off = offAttributes(props.deploymentType);
|
|
214
|
+
const request = (bearer) => bearer.pipe(Effect.flatMap((token) => requestNewClient(props, token)), Effect.catchTag("SharedHubClientFailed", (error) => Effect.logWarning(`${label}: ${error.reason}. The next deploy asks again.`).pipe(Effect.as(off))));
|
|
215
|
+
if (props.deploymentType === "development") {
|
|
216
|
+
const token = Redacted.value(props.initialAccessToken);
|
|
217
|
+
return token === "" ? Effect.logInfo("Convex.SharedHubClient: deploy to main once for Google sign-in on this laptop.").pipe(Effect.as(off)) : request(Effect.succeed(token));
|
|
218
|
+
}
|
|
152
219
|
const requestUrl = process.env["ACTIONS_ID_TOKEN_REQUEST_URL"];
|
|
153
220
|
const requestToken = process.env["ACTIONS_ID_TOKEN_REQUEST_TOKEN"];
|
|
154
221
|
if (!requestUrl || !requestToken) return (process.env["GITHUB_ACTIONS"] === "true" ? Effect.logWarning(`${label}: this workflow needs the permission id-token: write.`) : Effect.logInfo(`${label}: this run has no GitHub OIDC token.`)).pipe(Effect.as(off));
|
|
155
|
-
return
|
|
222
|
+
return request(readOidcToken(requestUrl, requestToken));
|
|
156
223
|
};
|
|
157
224
|
/** The client after a read on the hub, or undefined when the hub says that the client is gone. */
|
|
158
225
|
const renew = (saved) => Effect.gen(function* () {
|
|
@@ -172,12 +239,57 @@ const renew = (saved) => Effect.gen(function* () {
|
|
|
172
239
|
default: return yield* new SharedHubClientFailed({ reason: `The identity hub answered HTTP ${status}` });
|
|
173
240
|
}
|
|
174
241
|
}).pipe(Effect.catchTag("SharedHubClientFailed", (error) => Effect.logWarning(`Convex.SharedHubClient: ${saved.redirectUri} keeps the saved client: ${error.reason}.`).pipe(Effect.as(saved))));
|
|
242
|
+
/** A saved row of this resource, whose `attr` `savedAttributes` reads. */
|
|
243
|
+
const SavedRow = Schema.Struct({
|
|
244
|
+
resourceType: Schema.Literal("IdentityHub.SharedClient"),
|
|
245
|
+
attr: Schema.Unknown
|
|
246
|
+
});
|
|
247
|
+
/**
|
|
248
|
+
* The attributes of the client `id` in another stage of this stack, such as
|
|
249
|
+
* `read("HubClient", { stage: "prod" })` in the stage of a laptop, as Outputs: the
|
|
250
|
+
* `initialAccessToken` and the `repository` of the production client. Without that row, the off
|
|
251
|
+
* attributes, with `deploymentType: "production"`.
|
|
252
|
+
*
|
|
253
|
+
* `SharedHubClient.ref` is not enough: Alchemy fails the plan with `InvalidReferenceError` when the
|
|
254
|
+
* row is missing, and a laptop before the first deploy to main has none. This read runs when the
|
|
255
|
+
* plan resolves it, with the State service of the running stack and its `get`, so it never creates
|
|
256
|
+
* or upgrades a store. The store revives each secret as `Redacted`, as it does for every read.
|
|
257
|
+
*/
|
|
258
|
+
const read = (id, options) => Output.fromEffect(Effect.gen(function* () {
|
|
259
|
+
const stack = yield* Stack;
|
|
260
|
+
const row = yield* (yield* yield* State).get({
|
|
261
|
+
stack: stack.name,
|
|
262
|
+
stage: options.stage,
|
|
263
|
+
fqn: id
|
|
264
|
+
}).pipe(Effect.orDie);
|
|
265
|
+
if (row === void 0) return offAttributes("production");
|
|
266
|
+
const { attr } = yield* Schema.decodeUnknownEffect(SavedRow)(row).pipe(Effect.catchCause(() => Effect.die(new SharedHubClientFailed({ reason: `the row ${id} of stage ${options.stage} is not a Convex.SharedHubClient` }))));
|
|
267
|
+
return yield* savedAttributes(attr);
|
|
268
|
+
}));
|
|
269
|
+
/**
|
|
270
|
+
* The three values that the app's deployment gets, such as `HUB_ISSUER`, `HUB_CLIENT_ID`, and
|
|
271
|
+
* `HUB_CLIENT_SECRET`, as Outputs: the client's values when `status` is `on`, else the off values,
|
|
272
|
+
* so that a suspended client gives guest sign-in and keeps its values in state.
|
|
273
|
+
*/
|
|
274
|
+
const variables = (client) => Output.map(Output.of(client), (attributes) => attributes.status === "on" ? {
|
|
275
|
+
issuer: attributes.issuer,
|
|
276
|
+
clientId: attributes.clientId,
|
|
277
|
+
clientSecret: attributes.clientSecret
|
|
278
|
+
} : {
|
|
279
|
+
issuer: OFF_VALUES.issuer,
|
|
280
|
+
clientId: OFF_VALUES.clientId,
|
|
281
|
+
clientSecret: Redacted.make(OFF_VALUES.clientSecret)
|
|
282
|
+
});
|
|
283
|
+
const SharedHubClient = Resource("IdentityHub.SharedClient")({
|
|
284
|
+
read,
|
|
285
|
+
variables
|
|
286
|
+
});
|
|
175
287
|
const SharedHubClientProvider = () => Provider.succeed(SharedHubClient, {
|
|
176
288
|
diff: ({ news }) => Effect.succeed(isResolved(news) ? { action: "update" } : void 0),
|
|
177
289
|
read: ({ output }) => Effect.succeed(output),
|
|
178
290
|
reconcile: Effect.fn(function* ({ news, output }) {
|
|
179
|
-
const saved = output
|
|
180
|
-
return (saved?.redirectUri === news.redirectUri ? yield* renew(saved) : void 0) ?? (yield* newClient(news));
|
|
291
|
+
const saved = output === void 0 || output.status === "off" ? void 0 : yield* savedAttributes(output);
|
|
292
|
+
return (saved?.redirectUri === news.redirectUri && saved.deploymentType === news.deploymentType ? yield* renew(saved) : void 0) ?? (yield* newClient(news));
|
|
181
293
|
}),
|
|
182
294
|
delete: ({ output }) => output.deploymentType === "production" || output.status === "off" ? Effect.void : Effect.gen(function* () {
|
|
183
295
|
const { status } = yield* send("The identity hub", output.registrationClientUri, Redacted.value(output.registrationAccessToken), { method: "DELETE" });
|
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, on any host.\n//\n// The run asks the hub for a new client with a GitHub OIDC token, so the stack needs no key and no\n// Samebase account. The token names the repository. The hub reads `/samebase-verification.json` at\n// the 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 /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 /** The terms of the shared sign-in service, which the log shows once, for the new client. */\n terms: Schema.NonEmptyString,\n});\n\n/** The other answers of `POST /hub-clients`, by status. */\nconst REFUSALS: Readonly<Record<number, string>> = {\n 400: \"the identity hub refused the request: the return address must be https on a DNS name, and the app name at most 64 characters\",\n 401: \"the identity hub refused the GitHub OIDC token\",\n 403: \"the identity hub does not allow this repository or return address\",\n 404: \"the identity hub has no /hub-clients\",\n 429: \"the identity hub limits new clients\",\n 502: \"GitHub's keys are not available to the identity hub\",\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 [\"paused\", \"the identity hub pauses new clients of third-party apps\"],\n [\"run_not_allowed\", \"the identity hub gives clients only to runs of main and of pull requests\"],\n [\"blocked\", \"Samebase blocked this repository or its owner\"],\n [\"suspended\", \"the identity hub suspended this repository\"],\n [\"too_many_apps\", \"the repository has as many apps on the identity hub as it may\"],\n [\"too_many_clients\", \"the app has as many clients on the identity hub as it may\"],\n [\"app_taken\", \"the app's name belongs to another app on the identity hub\"],\n [\"host_unreachable\", \"the identity hub 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]);\n\n/** The windows of a 429, by the name that the answer gives. */\nconst RATE_LIMITED = new Map([\n [\"repository\", \"the identity hub gives one repository at most 30 new clients an hour\"],\n [\"owner\", \"the identity hub gives one owner at most 100 new clients an hour\"],\n [\"new_owners\", \"the identity hub takes at most 50 new owners an hour\"],\n]);\n\nconst Refusal = Schema.Struct({ reason: Schema.String });\nconst RateLimited = Schema.Struct({ window: Schema.String });\n\n/** The text of a refusal: a known reason of a 403 or window of a 429, else the text of its status. */\nconst refusalOf = (status: number, body: unknown) => {\n const known =\n status === 403\n ? NOT_ALLOWED.get(\n Option.getOrUndefined(Schema.decodeUnknownOption(Refusal)(body))?.reason ?? \"\",\n )\n : status === 429\n ? RATE_LIMITED.get(\n Option.getOrUndefined(Schema.decodeUnknownOption(RateLimited)(body))?.window ?? \"\",\n )\n : undefined;\n return known ?? REFUSALS[status] ?? \"the identity hub refused the request\";\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 \"The identity hub\",\n `${process.env[\"HUB_URL\"] ?? \"https://auth.samebase.com\"}/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, \"The identity hub\")(body);\n yield* Effect.logInfo(\n `Convex.SharedHubClient: ${props.app} on ${client.redirect_uri} has a new client of the identity hub. Its use is under ${client.terms}.`,\n );\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 another CI. In a workflow, `id-token: write` gives both, so a workflow without them\n // 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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"}
|
|
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, and on a laptop as a stage of the same\n// stack.\n//\n// A run in GitHub Actions asks the hub for a new client with a GitHub OIDC token, so the stack needs\n// no key and no Samebase account. The token names the repository. The hub reads\n// `/samebase-verification.json` at the origin of the return address, which must name the same\n// repository, and registers the client on that address. The answer holds the client's own\n// registration access token (RFC 7591), and each later deploy reads the client on the hub with that\n// token (RFC 7592). The hub keeps only hashes, so the state is the only record of the secret and the\n// token.\n//\n// The answer to a production registration also holds the app's initial access token (RFC 7591\n// section 1.2) and the repository id. The state of stage `prod` keeps them. A laptop runs the stack\n// as a stage `dev-<name>`: it reads them with `SharedHubClient.read`, and a client with\n// `deploymentType: \"development\"` asks the hub with the token in place of the GitHub token (RFC 133).\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 or type. 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 Output from \"alchemy/Output\";\nimport * as Provider from \"alchemy/Provider\";\nimport { Stack } from \"alchemy/Stack\";\nimport { State } from \"alchemy/State\";\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\n/** The variables of a deployment without a hub client, as the starter writes them. */\nconst OFF_VALUES = { issuer: \"http://127.0.0.1:1\", clientId: \"no-hub\", clientSecret: \"no-hub\" };\n\ninterface CommonProps {\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. A\n * development client can also have http on a loopback host, which needs no file.\n */\n readonly redirectUri: string;\n /** Recorded on the hub's client, such as the commit. The hub records \"unknown\" without it. */\n readonly softwareVersion?: string;\n}\n\n/**\n * A production or preview client asks with the GitHub OIDC token of the run. A development client\n * asks with the app's initial access token, which `SharedHubClient.read` gives from stage `prod`.\n */\nexport type SharedHubClientProps =\n | (CommonProps & {\n readonly deploymentType: \"production\" | \"preview\";\n readonly initialAccessToken?: never;\n })\n | (CommonProps & {\n readonly deploymentType: \"development\";\n /** Empty before the first production registration: the client gets the off values. */\n readonly initialAccessToken: Redacted.Redacted<string>;\n });\n\nconst DeploymentType = Schema.Literals([\"production\", \"preview\", \"development\"]);\n\nconst Attributes = Schema.Struct({\n /** `off`: no client, and the values below are the off values. */\n status: Schema.Literals([\"on\", \"off\", \"suspended\"]),\n /** The issuer of the hub, which the deployment names to sign in. */\n issuer: Schema.String,\n clientId: Schema.String,\n /** Returned only at registration. */\n clientSecret: Schema.Redacted(Schema.String),\n /** Returned only at registration. It reads and deletes this client on the hub, and nothing else. */\n registrationAccessToken: Schema.Redacted(Schema.String),\n registrationClientUri: Schema.String,\n deploymentType: DeploymentType,\n /** The return address that the client serves. Empty for the off values. */\n redirectUri: Schema.String,\n /**\n * Returned only at a production registration (RFC 7591 section 1.2). It registers development\n * clients of this app. Empty for a preview, for a development client, and for the off values.\n */\n initialAccessToken: Schema.Redacted(Schema.String),\n /** The numeric GitHub id of the repository, with the initial access token. Empty without it. */\n repository: Schema.String,\n});\n\n/**\n * The attributes keep the real values of a suspended client, so that an unblock needs no new client.\n * `SharedHubClient.variables` gives the values that the app gets.\n */\nexport interface SharedHubClientAttributes extends Schema.Schema.Type<typeof Attributes> {}\n\n/**\n * The attributes as the state saves them. A row of 0.25 and earlier has no token fields, and they\n * stay empty until the next production registration.\n */\nconst SavedAttributes = Schema.Struct({\n ...Attributes.fields,\n initialAccessToken: Schema.optionalKey(Schema.Redacted(Schema.String)),\n repository: Schema.optionalKey(Schema.String),\n});\n\nconst savedAttributes = (value: unknown) =>\n Schema.decodeUnknownEffect(SavedAttributes)(value).pipe(\n Effect.map(({ initialAccessToken = Redacted.make(\"\"), repository = \"\", ...attributes }) => ({\n ...attributes,\n initialAccessToken,\n repository,\n })),\n // The text of a schema issue can quote a value of the row.\n Effect.catchCause(() =>\n Effect.die(\n new SharedHubClientFailed({\n reason: \"the state holds attributes of Convex.SharedHubClient that it cannot read\",\n }),\n ),\n ),\n );\n\n/** The attributes of no client. */\nconst offAttributes = (\n deploymentType: SharedHubClientAttributes[\"deploymentType\"],\n): SharedHubClientAttributes => ({\n status: \"off\",\n issuer: OFF_VALUES.issuer,\n clientId: OFF_VALUES.clientId,\n clientSecret: Redacted.make(OFF_VALUES.clientSecret),\n registrationAccessToken: Redacted.make(\"\"),\n registrationClientUri: \"\",\n deploymentType,\n redirectUri: \"\",\n initialAccessToken: Redacted.make(\"\"),\n repository: \"\",\n});\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 /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 /** The terms of the shared sign-in service, which the log shows once, for the new client. */\n terms: Schema.NonEmptyString,\n /** Only in the answer to a production registration with the GitHub token. */\n initial_access_token: Schema.optionalKey(Schema.NonEmptyString),\n repository: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^\\d+$/))),\n});\n\n/** The other answers of `POST /hub-clients`, by status. */\nconst REFUSALS: Readonly<Record<number, string>> = {\n 400: \"the identity hub refused the request: the return address must be https on a DNS name, and the app name at most 64 characters\",\n 401: \"the identity hub refused the GitHub OIDC token\",\n 403: \"the identity hub does not allow this repository or return address\",\n 404: \"the identity hub has no /hub-clients\",\n 429: \"the identity hub limits new clients\",\n 502: \"GitHub's keys are not available to the identity hub\",\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 [\"paused\", \"the identity hub pauses new clients of third-party apps\"],\n [\"run_not_allowed\", \"the identity hub gives clients only to runs of main and of pull requests\"],\n [\"blocked\", \"Samebase blocked this repository or its owner\"],\n [\"suspended\", \"the identity hub suspended this repository\"],\n [\"too_many_apps\", \"the repository has as many apps on the identity hub as it may\"],\n [\"too_many_clients\", \"the app has as many clients on the identity hub as it may\"],\n [\"app_taken\", \"the app's name belongs to another app on the identity hub\"],\n [\"host_unreachable\", \"the identity hub 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 [\"app_mismatch\", \"the initial access token belongs to another app on the identity hub\"],\n]);\n\n/** The windows of a 429, by the name that the answer gives. */\nconst RATE_LIMITED = new Map([\n [\"repository\", \"the identity hub gives one repository at most 30 new clients an hour\"],\n [\"owner\", \"the identity hub gives one owner at most 100 new clients an hour\"],\n [\"new_owners\", \"the identity hub takes at most 50 new owners an hour\"],\n]);\n\nconst Refusal = Schema.Struct({ reason: Schema.String });\nconst RateLimited = Schema.Struct({ window: Schema.String });\n\n/** The text of a refusal: a known reason of a 403 or window of a 429, else the text of its status. */\nconst refusalOf = (status: number, body: unknown) => {\n const known =\n status === 403\n ? NOT_ALLOWED.get(\n Option.getOrUndefined(Schema.decodeUnknownOption(Refusal)(body))?.reason ?? \"\",\n )\n : status === 429\n ? RATE_LIMITED.get(\n Option.getOrUndefined(Schema.decodeUnknownOption(RateLimited)(body))?.window ?? \"\",\n )\n : undefined;\n return known ?? REFUSALS[status] ?? \"the identity hub refused the request\";\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\n/** `POST /hub-clients` with `bearer`: the GitHub OIDC token, or the app's initial access token. */\nconst requestNewClient = (props: SharedHubClientProps, bearer: string) =>\n Effect.gen(function* () {\n const { body, status } = yield* send(\n \"The identity hub\",\n `${process.env[\"HUB_URL\"] ?? \"https://auth.samebase.com\"}/hub-clients`,\n bearer,\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 const reason =\n status === 401 && props.deploymentType === \"development\"\n ? \"the identity hub refused the initial access token\"\n : refusalOf(status, body);\n return yield* new SharedHubClientFailed({ reason: `${reason} (HTTP ${status})` });\n }\n const client = yield* decode(NewClient, \"The identity hub\")(body);\n yield* Effect.logInfo(\n `Convex.SharedHubClient: ${props.app} on ${client.redirect_uri} has a new client of the identity hub. Its use is under ${client.terms}.`,\n );\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 initialAccessToken: Redacted.make(client.initial_access_token ?? \"\"),\n repository: client.repository ?? \"\",\n };\n });\n\n/** A new client, or the off values. */\nconst newClient = (props: SharedHubClientProps) => {\n const label = `Convex.SharedHubClient: ${props.app} on ${props.redirectUri} deploys without the hub`;\n const off = offAttributes(props.deploymentType);\n const request = (bearer: Effect.Effect<string, SharedHubClientFailed>) =>\n bearer.pipe(\n Effect.flatMap((token) => requestNewClient(props, token)),\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 if (props.deploymentType === \"development\") {\n const token = Redacted.value(props.initialAccessToken);\n return token === \"\"\n ? Effect.logInfo(\n \"Convex.SharedHubClient: deploy to main once for Google sign-in on this laptop.\",\n ).pipe(Effect.as(off))\n : request(Effect.succeed(token));\n }\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 another CI. In a workflow, `id-token: write` gives both, so a workflow without them\n // 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 request(readOidcToken(requestUrl, requestToken));\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\n/** A saved row of this resource, whose `attr` `savedAttributes` reads. */\nconst SavedRow = Schema.Struct({\n resourceType: Schema.Literal(\"IdentityHub.SharedClient\"),\n attr: Schema.Unknown,\n});\n\n/**\n * The attributes of the client `id` in another stage of this stack, such as\n * `read(\"HubClient\", { stage: \"prod\" })` in the stage of a laptop, as Outputs: the\n * `initialAccessToken` and the `repository` of the production client. Without that row, the off\n * attributes, with `deploymentType: \"production\"`.\n *\n * `SharedHubClient.ref` is not enough: Alchemy fails the plan with `InvalidReferenceError` when the\n * row is missing, and a laptop before the first deploy to main has none. This read runs when the\n * plan resolves it, with the State service of the running stack and its `get`, so it never creates\n * or upgrades a store. The store revives each secret as `Redacted`, as it does for every read.\n */\nconst read = (id: string, options: { readonly stage: string }) =>\n Output.fromEffect(\n Effect.gen(function* () {\n const stack = yield* Stack;\n const state = yield* yield* State;\n const row = yield* state\n .get({ stack: stack.name, stage: options.stage, fqn: id })\n .pipe(Effect.orDie);\n if (row === undefined) return offAttributes(\"production\");\n const { attr } = yield* Schema.decodeUnknownEffect(SavedRow)(row).pipe(\n Effect.catchCause(() =>\n Effect.die(\n new SharedHubClientFailed({\n reason: `the row ${id} of stage ${options.stage} is not a Convex.SharedHubClient`,\n }),\n ),\n ),\n );\n return yield* savedAttributes(attr);\n }),\n );\n\n/**\n * The three values that the app's deployment gets, such as `HUB_ISSUER`, `HUB_CLIENT_ID`, and\n * `HUB_CLIENT_SECRET`, as Outputs: the client's values when `status` is `on`, else the off values,\n * so that a suspended client gives guest sign-in and keeps its values in state.\n */\nconst variables = (client: SharedHubClient) =>\n Output.map(Output.of(client), (attributes) =>\n attributes.status === \"on\"\n ? {\n issuer: attributes.issuer,\n clientId: attributes.clientId,\n clientSecret: attributes.clientSecret,\n }\n : {\n issuer: OFF_VALUES.issuer,\n clientId: OFF_VALUES.clientId,\n clientSecret: Redacted.make(OFF_VALUES.clientSecret),\n },\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 read,\n variables,\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 // Only a client of the same return address and type is renewed.\n reconcile: Effect.fn(function* ({ news, output }) {\n const saved =\n output === undefined || output.status === \"off\"\n ? undefined\n : yield* savedAttributes(output);\n const renewed =\n saved?.redirectUri === news.redirectUri && saved.deploymentType === news.deploymentType\n ? yield* renew(saved)\n : undefined;\n return renewed ?? (yield* newClient(news));\n }),\n\n // A failed delete does not stop a destroy: the removal date of a preview or development client\n // cleans it up. 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
package/src/SharedHubClient.ts
CHANGED
|
@@ -1,24 +1,34 @@
|
|
|
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, on any host
|
|
2
|
+
// app that deploys in its own GitHub Actions run, on any host, and on a laptop as a stage of the same
|
|
3
|
+
// stack.
|
|
3
4
|
//
|
|
4
|
-
//
|
|
5
|
-
// Samebase account. The token names the repository. The hub reads
|
|
6
|
-
// the origin of the return address, which must name the same
|
|
7
|
-
// that address. The answer holds the client's own
|
|
8
|
-
// later deploy reads the client on the hub with that
|
|
9
|
-
// the state is the only record of the secret and the
|
|
5
|
+
// A run in GitHub Actions asks the hub for a new client with a GitHub OIDC token, so the stack needs
|
|
6
|
+
// no key and no Samebase account. The token names the repository. The hub reads
|
|
7
|
+
// `/samebase-verification.json` at the origin of the return address, which must name the same
|
|
8
|
+
// repository, and registers the client on that address. The answer holds the client's own
|
|
9
|
+
// registration access token (RFC 7591), and each later deploy reads the client on the hub with that
|
|
10
|
+
// token (RFC 7592). The hub keeps only hashes, so the state is the only record of the secret and the
|
|
11
|
+
// token.
|
|
12
|
+
//
|
|
13
|
+
// The answer to a production registration also holds the app's initial access token (RFC 7591
|
|
14
|
+
// section 1.2) and the repository id. The state of stage `prod` keeps them. A laptop runs the stack
|
|
15
|
+
// as a stage `dev-<name>`: it reads them with `SharedHubClient.read`, and a client with
|
|
16
|
+
// `deploymentType: "development"` asks the hub with the token in place of the GitHub token (RFC 133).
|
|
10
17
|
//
|
|
11
18
|
// A push removes a variable that a deploy stops passing, so a failure here must not drop working
|
|
12
19
|
// variables or stop the deploy. A renewal that fails keeps the saved values. Only a client that
|
|
13
|
-
// cannot work asks for a new one: it is gone, or it has another return address. If that
|
|
14
|
-
// fails, the run gives the off values, so the app has guest sign-in and not a broken button.
|
|
20
|
+
// cannot work asks for a new one: it is gone, or it has another return address or type. If that
|
|
21
|
+
// request fails, the run gives the off values, so the app has guest sign-in and not a broken button.
|
|
15
22
|
// The off values are the `no-hub` variables of a checkout without a hub: Convex needs the variables,
|
|
16
23
|
// and the app hides the button for this client ID.
|
|
17
24
|
//
|
|
18
25
|
// No text in this file quotes an answer or a fetch error, so no log line can hold a secret.
|
|
19
26
|
import { Resource } from "alchemy";
|
|
20
27
|
import { isResolved } from "alchemy/Diff";
|
|
28
|
+
import * as Output from "alchemy/Output";
|
|
21
29
|
import * as Provider from "alchemy/Provider";
|
|
30
|
+
import { Stack } from "alchemy/Stack";
|
|
31
|
+
import { State } from "alchemy/State";
|
|
22
32
|
import * as Effect from "effect/Effect";
|
|
23
33
|
import * as Option from "effect/Option";
|
|
24
34
|
import * as Redacted from "effect/Redacted";
|
|
@@ -28,44 +38,111 @@ import type { Providers } from "./Providers.ts";
|
|
|
28
38
|
/** A hung service must not hold a deploy: the time limit of one request. */
|
|
29
39
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
30
40
|
|
|
31
|
-
|
|
41
|
+
/** The variables of a deployment without a hub client, as the starter writes them. */
|
|
42
|
+
const OFF_VALUES = { issuer: "http://127.0.0.1:1", clientId: "no-hub", clientSecret: "no-hub" };
|
|
43
|
+
|
|
44
|
+
interface CommonProps {
|
|
32
45
|
/** The stable name of the app, set once in the stack. */
|
|
33
46
|
readonly app: string;
|
|
34
47
|
/**
|
|
35
48
|
* The return address of the hub's sign-ins, such as `${site.siteUrl}/auth/hub/callback`: an https
|
|
36
49
|
* 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.
|
|
50
|
+
* repository, `{ "repository": "123" }`. It can come from a resource that this run creates. A
|
|
51
|
+
* development client can also have http on a loopback host, which needs no file.
|
|
38
52
|
*/
|
|
39
53
|
readonly redirectUri: string;
|
|
40
|
-
readonly deploymentType: "production" | "preview";
|
|
41
54
|
/** Recorded on the hub's client, such as the commit. The hub records "unknown" without it. */
|
|
42
55
|
readonly softwareVersion?: string;
|
|
43
56
|
}
|
|
44
57
|
|
|
45
|
-
|
|
58
|
+
/**
|
|
59
|
+
* A production or preview client asks with the GitHub OIDC token of the run. A development client
|
|
60
|
+
* asks with the app's initial access token, which `SharedHubClient.read` gives from stage `prod`.
|
|
61
|
+
*/
|
|
62
|
+
export type SharedHubClientProps =
|
|
63
|
+
| (CommonProps & {
|
|
64
|
+
readonly deploymentType: "production" | "preview";
|
|
65
|
+
readonly initialAccessToken?: never;
|
|
66
|
+
})
|
|
67
|
+
| (CommonProps & {
|
|
68
|
+
readonly deploymentType: "development";
|
|
69
|
+
/** Empty before the first production registration: the client gets the off values. */
|
|
70
|
+
readonly initialAccessToken: Redacted.Redacted<string>;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const DeploymentType = Schema.Literals(["production", "preview", "development"]);
|
|
74
|
+
|
|
75
|
+
const Attributes = Schema.Struct({
|
|
46
76
|
/** `off`: no client, and the values below are the off values. */
|
|
47
|
-
|
|
77
|
+
status: Schema.Literals(["on", "off", "suspended"]),
|
|
48
78
|
/** The issuer of the hub, which the deployment names to sign in. */
|
|
49
|
-
|
|
50
|
-
|
|
79
|
+
issuer: Schema.String,
|
|
80
|
+
clientId: Schema.String,
|
|
51
81
|
/** Returned only at registration. */
|
|
52
|
-
|
|
82
|
+
clientSecret: Schema.Redacted(Schema.String),
|
|
53
83
|
/** Returned only at registration. It reads and deletes this client on the hub, and nothing else. */
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
84
|
+
registrationAccessToken: Schema.Redacted(Schema.String),
|
|
85
|
+
registrationClientUri: Schema.String,
|
|
86
|
+
deploymentType: DeploymentType,
|
|
57
87
|
/** The return address that the client serves. Empty for the off values. */
|
|
58
|
-
|
|
59
|
-
|
|
88
|
+
redirectUri: Schema.String,
|
|
89
|
+
/**
|
|
90
|
+
* Returned only at a production registration (RFC 7591 section 1.2). It registers development
|
|
91
|
+
* clients of this app. Empty for a preview, for a development client, and for the off values.
|
|
92
|
+
*/
|
|
93
|
+
initialAccessToken: Schema.Redacted(Schema.String),
|
|
94
|
+
/** The numeric GitHub id of the repository, with the initial access token. Empty without it. */
|
|
95
|
+
repository: Schema.String,
|
|
96
|
+
});
|
|
60
97
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
98
|
+
/**
|
|
99
|
+
* The attributes keep the real values of a suspended client, so that an unblock needs no new client.
|
|
100
|
+
* `SharedHubClient.variables` gives the values that the app gets.
|
|
101
|
+
*/
|
|
102
|
+
export interface SharedHubClientAttributes extends Schema.Schema.Type<typeof Attributes> {}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The attributes as the state saves them. A row of 0.25 and earlier has no token fields, and they
|
|
106
|
+
* stay empty until the next production registration.
|
|
107
|
+
*/
|
|
108
|
+
const SavedAttributes = Schema.Struct({
|
|
109
|
+
...Attributes.fields,
|
|
110
|
+
initialAccessToken: Schema.optionalKey(Schema.Redacted(Schema.String)),
|
|
111
|
+
repository: Schema.optionalKey(Schema.String),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const savedAttributes = (value: unknown) =>
|
|
115
|
+
Schema.decodeUnknownEffect(SavedAttributes)(value).pipe(
|
|
116
|
+
Effect.map(({ initialAccessToken = Redacted.make(""), repository = "", ...attributes }) => ({
|
|
117
|
+
...attributes,
|
|
118
|
+
initialAccessToken,
|
|
119
|
+
repository,
|
|
120
|
+
})),
|
|
121
|
+
// The text of a schema issue can quote a value of the row.
|
|
122
|
+
Effect.catchCause(() =>
|
|
123
|
+
Effect.die(
|
|
124
|
+
new SharedHubClientFailed({
|
|
125
|
+
reason: "the state holds attributes of Convex.SharedHubClient that it cannot read",
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
/** The attributes of no client. */
|
|
132
|
+
const offAttributes = (
|
|
133
|
+
deploymentType: SharedHubClientAttributes["deploymentType"],
|
|
134
|
+
): SharedHubClientAttributes => ({
|
|
135
|
+
status: "off",
|
|
136
|
+
issuer: OFF_VALUES.issuer,
|
|
137
|
+
clientId: OFF_VALUES.clientId,
|
|
138
|
+
clientSecret: Redacted.make(OFF_VALUES.clientSecret),
|
|
139
|
+
registrationAccessToken: Redacted.make(""),
|
|
140
|
+
registrationClientUri: "",
|
|
141
|
+
deploymentType,
|
|
142
|
+
redirectUri: "",
|
|
143
|
+
initialAccessToken: Redacted.make(""),
|
|
144
|
+
repository: "",
|
|
145
|
+
});
|
|
69
146
|
|
|
70
147
|
/** One request gave no usable answer. The reason holds no secret. */
|
|
71
148
|
class SharedHubClientFailed extends Schema.TaggedError<SharedHubClientFailed>()(
|
|
@@ -85,6 +162,9 @@ const NewClient = Schema.Struct({
|
|
|
85
162
|
redirect_uri: Schema.NonEmptyString,
|
|
86
163
|
/** The terms of the shared sign-in service, which the log shows once, for the new client. */
|
|
87
164
|
terms: Schema.NonEmptyString,
|
|
165
|
+
/** Only in the answer to a production registration with the GitHub token. */
|
|
166
|
+
initial_access_token: Schema.optionalKey(Schema.NonEmptyString),
|
|
167
|
+
repository: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^\d+$/))),
|
|
88
168
|
});
|
|
89
169
|
|
|
90
170
|
/** The other answers of `POST /hub-clients`, by status. */
|
|
@@ -113,6 +193,7 @@ const NOT_ALLOWED = new Map([
|
|
|
113
193
|
],
|
|
114
194
|
["verification_file_invalid", "/samebase-verification.json on the host holds no repository id"],
|
|
115
195
|
["repository_mismatch", "/samebase-verification.json on the host names another repository"],
|
|
196
|
+
["app_mismatch", "the initial access token belongs to another app on the identity hub"],
|
|
116
197
|
]);
|
|
117
198
|
|
|
118
199
|
/** The windows of a 429, by the name that the answer gives. */
|
|
@@ -178,13 +259,13 @@ const readOidcToken = (requestUrl: string, requestToken: string) =>
|
|
|
178
259
|
return (yield* decode(OidcToken, "GitHub")(body)).value;
|
|
179
260
|
});
|
|
180
261
|
|
|
181
|
-
|
|
262
|
+
/** `POST /hub-clients` with `bearer`: the GitHub OIDC token, or the app's initial access token. */
|
|
263
|
+
const requestNewClient = (props: SharedHubClientProps, bearer: string) =>
|
|
182
264
|
Effect.gen(function* () {
|
|
183
|
-
const token = yield* readOidcToken(requestUrl, requestToken);
|
|
184
265
|
const { body, status } = yield* send(
|
|
185
266
|
"The identity hub",
|
|
186
267
|
`${process.env["HUB_URL"] ?? "https://auth.samebase.com"}/hub-clients`,
|
|
187
|
-
|
|
268
|
+
bearer,
|
|
188
269
|
{
|
|
189
270
|
method: "POST",
|
|
190
271
|
body: JSON.stringify({
|
|
@@ -195,9 +276,11 @@ const requestNewClient = (props: SharedHubClientProps, requestUrl: string, reque
|
|
|
195
276
|
},
|
|
196
277
|
);
|
|
197
278
|
if (status !== 201) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
279
|
+
const reason =
|
|
280
|
+
status === 401 && props.deploymentType === "development"
|
|
281
|
+
? "the identity hub refused the initial access token"
|
|
282
|
+
: refusalOf(status, body);
|
|
283
|
+
return yield* new SharedHubClientFailed({ reason: `${reason} (HTTP ${status})` });
|
|
201
284
|
}
|
|
202
285
|
const client = yield* decode(NewClient, "The identity hub")(body);
|
|
203
286
|
yield* Effect.logInfo(
|
|
@@ -212,22 +295,34 @@ const requestNewClient = (props: SharedHubClientProps, requestUrl: string, reque
|
|
|
212
295
|
registrationClientUri: client.registration_client_uri,
|
|
213
296
|
deploymentType: props.deploymentType,
|
|
214
297
|
redirectUri: client.redirect_uri,
|
|
298
|
+
initialAccessToken: Redacted.make(client.initial_access_token ?? ""),
|
|
299
|
+
repository: client.repository ?? "",
|
|
215
300
|
};
|
|
216
301
|
});
|
|
217
302
|
|
|
218
|
-
/** A new client, or
|
|
303
|
+
/** A new client, or the off values. */
|
|
219
304
|
const newClient = (props: SharedHubClientProps) => {
|
|
220
305
|
const label = `Convex.SharedHubClient: ${props.app} on ${props.redirectUri} deploys without the hub`;
|
|
221
|
-
const off
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
306
|
+
const off = offAttributes(props.deploymentType);
|
|
307
|
+
const request = (bearer: Effect.Effect<string, SharedHubClientFailed>) =>
|
|
308
|
+
bearer.pipe(
|
|
309
|
+
Effect.flatMap((token) => requestNewClient(props, token)),
|
|
310
|
+
Effect.catchTag("SharedHubClientFailed", (error) =>
|
|
311
|
+
Effect.logWarning(`${label}: ${error.reason}. The next deploy asks again.`).pipe(
|
|
312
|
+
Effect.as(off),
|
|
313
|
+
),
|
|
314
|
+
),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
if (props.deploymentType === "development") {
|
|
318
|
+
const token = Redacted.value(props.initialAccessToken);
|
|
319
|
+
return token === ""
|
|
320
|
+
? Effect.logInfo(
|
|
321
|
+
"Convex.SharedHubClient: deploy to main once for Google sign-in on this laptop.",
|
|
322
|
+
).pipe(Effect.as(off))
|
|
323
|
+
: request(Effect.succeed(token));
|
|
324
|
+
}
|
|
325
|
+
|
|
231
326
|
const requestUrl = process.env["ACTIONS_ID_TOKEN_REQUEST_URL"];
|
|
232
327
|
const requestToken = process.env["ACTIONS_ID_TOKEN_REQUEST_TOKEN"];
|
|
233
328
|
// A laptop, or another CI. In a workflow, `id-token: write` gives both, so a workflow without them
|
|
@@ -239,13 +334,7 @@ const newClient = (props: SharedHubClientProps) => {
|
|
|
239
334
|
: Effect.logInfo(`${label}: this run has no GitHub OIDC token.`)
|
|
240
335
|
).pipe(Effect.as(off));
|
|
241
336
|
}
|
|
242
|
-
return
|
|
243
|
-
Effect.catchTag("SharedHubClientFailed", (error) =>
|
|
244
|
-
Effect.logWarning(`${label}: ${error.reason}. The next deploy asks again.`).pipe(
|
|
245
|
-
Effect.as(off),
|
|
246
|
-
),
|
|
247
|
-
),
|
|
248
|
-
);
|
|
337
|
+
return request(readOidcToken(requestUrl, requestToken));
|
|
249
338
|
};
|
|
250
339
|
|
|
251
340
|
/** The client after a read on the hub, or undefined when the hub says that the client is gone. */
|
|
@@ -280,6 +369,77 @@ const renew = (saved: SharedHubClientAttributes) =>
|
|
|
280
369
|
),
|
|
281
370
|
);
|
|
282
371
|
|
|
372
|
+
/** A saved row of this resource, whose `attr` `savedAttributes` reads. */
|
|
373
|
+
const SavedRow = Schema.Struct({
|
|
374
|
+
resourceType: Schema.Literal("IdentityHub.SharedClient"),
|
|
375
|
+
attr: Schema.Unknown,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The attributes of the client `id` in another stage of this stack, such as
|
|
380
|
+
* `read("HubClient", { stage: "prod" })` in the stage of a laptop, as Outputs: the
|
|
381
|
+
* `initialAccessToken` and the `repository` of the production client. Without that row, the off
|
|
382
|
+
* attributes, with `deploymentType: "production"`.
|
|
383
|
+
*
|
|
384
|
+
* `SharedHubClient.ref` is not enough: Alchemy fails the plan with `InvalidReferenceError` when the
|
|
385
|
+
* row is missing, and a laptop before the first deploy to main has none. This read runs when the
|
|
386
|
+
* plan resolves it, with the State service of the running stack and its `get`, so it never creates
|
|
387
|
+
* or upgrades a store. The store revives each secret as `Redacted`, as it does for every read.
|
|
388
|
+
*/
|
|
389
|
+
const read = (id: string, options: { readonly stage: string }) =>
|
|
390
|
+
Output.fromEffect(
|
|
391
|
+
Effect.gen(function* () {
|
|
392
|
+
const stack = yield* Stack;
|
|
393
|
+
const state = yield* yield* State;
|
|
394
|
+
const row = yield* state
|
|
395
|
+
.get({ stack: stack.name, stage: options.stage, fqn: id })
|
|
396
|
+
.pipe(Effect.orDie);
|
|
397
|
+
if (row === undefined) return offAttributes("production");
|
|
398
|
+
const { attr } = yield* Schema.decodeUnknownEffect(SavedRow)(row).pipe(
|
|
399
|
+
Effect.catchCause(() =>
|
|
400
|
+
Effect.die(
|
|
401
|
+
new SharedHubClientFailed({
|
|
402
|
+
reason: `the row ${id} of stage ${options.stage} is not a Convex.SharedHubClient`,
|
|
403
|
+
}),
|
|
404
|
+
),
|
|
405
|
+
),
|
|
406
|
+
);
|
|
407
|
+
return yield* savedAttributes(attr);
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The three values that the app's deployment gets, such as `HUB_ISSUER`, `HUB_CLIENT_ID`, and
|
|
413
|
+
* `HUB_CLIENT_SECRET`, as Outputs: the client's values when `status` is `on`, else the off values,
|
|
414
|
+
* so that a suspended client gives guest sign-in and keeps its values in state.
|
|
415
|
+
*/
|
|
416
|
+
const variables = (client: SharedHubClient) =>
|
|
417
|
+
Output.map(Output.of(client), (attributes) =>
|
|
418
|
+
attributes.status === "on"
|
|
419
|
+
? {
|
|
420
|
+
issuer: attributes.issuer,
|
|
421
|
+
clientId: attributes.clientId,
|
|
422
|
+
clientSecret: attributes.clientSecret,
|
|
423
|
+
}
|
|
424
|
+
: {
|
|
425
|
+
issuer: OFF_VALUES.issuer,
|
|
426
|
+
clientId: OFF_VALUES.clientId,
|
|
427
|
+
clientSecret: Redacted.make(OFF_VALUES.clientSecret),
|
|
428
|
+
},
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
export type SharedHubClient = Resource<
|
|
432
|
+
"IdentityHub.SharedClient",
|
|
433
|
+
SharedHubClientProps,
|
|
434
|
+
SharedHubClientAttributes,
|
|
435
|
+
never,
|
|
436
|
+
Providers
|
|
437
|
+
>;
|
|
438
|
+
export const SharedHubClient = Resource<SharedHubClient>("IdentityHub.SharedClient")({
|
|
439
|
+
read,
|
|
440
|
+
variables,
|
|
441
|
+
});
|
|
442
|
+
|
|
283
443
|
export const SharedHubClientProvider = () =>
|
|
284
444
|
Provider.succeed(SharedHubClient, {
|
|
285
445
|
// Always an update, so that each deploy reaches the hub. No attribute is stable, so a push
|
|
@@ -289,14 +449,21 @@ export const SharedHubClientProvider = () =>
|
|
|
289
449
|
|
|
290
450
|
read: ({ output }) => Effect.succeed(output),
|
|
291
451
|
|
|
452
|
+
// Only a client of the same return address and type is renewed.
|
|
292
453
|
reconcile: Effect.fn(function* ({ news, output }) {
|
|
293
|
-
const saved =
|
|
294
|
-
|
|
454
|
+
const saved =
|
|
455
|
+
output === undefined || output.status === "off"
|
|
456
|
+
? undefined
|
|
457
|
+
: yield* savedAttributes(output);
|
|
458
|
+
const renewed =
|
|
459
|
+
saved?.redirectUri === news.redirectUri && saved.deploymentType === news.deploymentType
|
|
460
|
+
? yield* renew(saved)
|
|
461
|
+
: undefined;
|
|
295
462
|
return renewed ?? (yield* newClient(news));
|
|
296
463
|
}),
|
|
297
464
|
|
|
298
|
-
// A failed delete does not stop a destroy: the removal date of a preview
|
|
299
|
-
// A destroy can leave a production deployment running, so its client stays.
|
|
465
|
+
// A failed delete does not stop a destroy: the removal date of a preview or development client
|
|
466
|
+
// cleans it up. A destroy can leave a production deployment running, so its client stays.
|
|
300
467
|
delete: ({ output }) =>
|
|
301
468
|
output.deploymentType === "production" || output.status === "off"
|
|
302
469
|
? Effect.void
|
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The commands of the package:
|
|
3
3
|
//
|
|
4
|
-
// alchemy-convex hub-client --team <team> --app <app> --callback-path <path>
|
|
4
|
+
// alchemy-convex hub-client --team <team> --app <app> --callback-path <path> [--env-prefix HUB]
|
|
5
5
|
// alchemy-convex stack-comment [--team <team>]
|
|
6
6
|
//
|
|
7
7
|
// hub-client runs in the directory of a Convex app at each dev start, and renews the client of the
|