@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 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`: `{ app, redirectUri, deploymentType, softwareVersion? }` gets a client
317
- of the central identity hub, for an app that deploys in its own GitHub Actions run, on any host.
318
- The workflow needs `permissions: id-token: write`, and no key and no Samebase account. `app` is at
319
- most 64 characters. The origin of `redirectUri` must serve `/samebase-verification.json` with the
320
- numeric GitHub id of the repository, `{ "repository": "123" }`, which
321
- `gh api repos/<owner>/<name> --jq .id` prints. The hub reads it before it registers the client, so
322
- the site must be live first. Outputs `status`, `issuer`, `clientId`, `clientSecret`,
323
- `registrationAccessToken`, `registrationClientUri`, `deploymentType`, and `redirectUri`.
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, or the hourly limit it reached.
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 a laptop, does the
331
- same, and logs no warning.
332
- - `status` is `suspended` when the hub suspended the client. The client keeps its values.
333
- - A destroy deletes a preview client. It does nothing to a production client, because the
334
- production deployment can stay.
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 command for it. Run it in the directory
338
- of the app, before the first push and at each dev start, with `CONVEX_ACCESS_TOKEN` or the Convex
339
- CLI login. It renews the client that `HUB_CLIENT_ID` names, so the client expires 14 days after the
340
- last dev start or use. A deployment without all three variables, or whose client the hub no longer
341
- has, gets a new client, and the command writes `HUB_ISSUER`, `HUB_CLIENT_ID`, and
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(\"&\", \"&amp;\")\n .replaceAll(\"<\", \"&lt;\")\n .replaceAll(\">\", \"&gt;\")\n .replaceAll('\"', \"&quot;\");\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":";;;;;;;;;;;AA4BA,MAAM,SAAS;;AAGf,MAAM,QACJ;AAEF,MAAM,aAAa;;AAGnB,MAAM,WAAW,OAAO,MAAM;CAC5B,OAAO,OAAO;EACZ,cAAc,OAAO,QAAQ,mBAAmB;EAChD,MAAM,OAAO,OAAO;GAAE,MAAM,OAAO;GAAQ,SAAS,OAAO;EAAO,CAAC;CACrE,CAAC;CACD,OAAO,OAAO;EACZ,cAAc,OAAO,QAAQ,mBAAmB;EAChD,MAAM,OAAO,OAAO;GAAE,YAAY,OAAO;GAAQ,KAAK,OAAO,SAAS,OAAO,MAAM;EAAE,CAAC;CACxF,CAAC;CACD,OAAO,OAAO;EACZ,cAAc,OAAO,QAAQ,sBAAsB;EACnD,MAAM,OAAO,OAAO;GAClB,YAAY,OAAO;GACnB,WAAW,OAAO;GAClB,cAAc,OAAO;EACvB,CAAC;CACH,CAAC;CACD,OAAO,OAAO;EACZ,cAAc,OAAO,QAAQ,0BAA0B;EACvD,MAAM,OAAO,OAAO,EAAE,QAAQ,OAAO,SAAS;GAAC;GAAM;GAAO;EAAW,CAAC,EAAE,CAAC;CAC7E,CAAC;AACH,CAAC;;AAID,MAAM,cAAc,OAAO,OAAO,GAC/B,iBAAiB,OAAO,OAAO;CAC9B,YAAY,OAAO;CACnB,QAAQ,OAAO,OAAO,OAAO,MAAM;CACnC,YAAY,OAAO;AACrB,CAAC,EACH,CAAC;AASD,IAAa,oBAAb,cAAuC,OAAO,YAA+B,CAAC,CAC5E,qBACA,EAAE,QAAQ,OAAO,OAAO,CAC1B,CAAC,CAAC;CACA,IAAa,UAAU;EACrB,OAAO,wCAAwC,KAAK;CACtD;AACF;;AAGA,MAAa,kBAAkB,EAAE,YAAY,YAC3C,OAAO,IAAI,aAAa;CACtB,MAAM,QAAQ,OAAO,OAAO;CAC5B,MAAM,SAAS,OAAO,MAAM,WAAW,EAAA,CAAG,QACvC,SAAS,SAAS,cAAc,KAAK,WAAW,GAAG,WAAW,EAAE,CACnE;CACA,MAAM,QAAqB,CAAC;CAC5B,KAAK,MAAM,QAAQ,OAAO;EACxB,IAAI,EAAE,OAAO,MAAM,WAAW,IAAI,EAAA,CAAG,SAAS,KAAK,GACjD;EAEF,MAAM,UAAU;GAAE,OAAO;GAAM;EAAM;EACrC,MAAM,OAAO,OAAO,OAAO,QAAQ,OAAO,MAAM,KAAK,OAAO,IAAI,QAC9D,MAAM,IAAI;GAAE,GAAG;GAAS;EAAI,CAAC,CAC/B;EACA,MAAM,SAAS,OAAO,MAAM,UAAU,OAAO;EAC7C,MAAM,KAAK;GACT;GACA;GACA,WAAW,KAAK,OAAO,OAAO,GAAG,QAAQ,CAAC;GAC1C,KAAK,OAAO,GAAG,WAAW,CAAC,CAAC,MAAM,IAAI,SAAS;EACjD,CAAC;CACH;CACA,OAAO;AACT,CAAC;;AAGH,MAAM,kBAAkB,cAAmC;CACzD,MAAM,8BAAc,IAAI,IAAY;CACpC,OAAO,UAAU,QAAQ,aAAa;EACpC,IAAI,SAAS,iBAAiB,qBAC5B,OAAO;EAET,MAAM,WAAW,YAAY,IAAI,SAAS,KAAK,IAAI;EACnD,YAAY,IAAI,SAAS,KAAK,IAAI;EAClC,OAAO,CAAC;CACV,CAAC;AACH;AAEA,MAAM,cAAc,SAClB,KACG,WAAW,KAAK,OAAO,CAAC,CACxB,WAAW,KAAK,MAAM,CAAC,CACvB,WAAW,KAAK,MAAM,CAAC,CACvB,WAAW,MAAK,QAAQ;AAE7B,MAAM,QAAQ,MAAc,SAAiB,YAAY,WAAW,IAAI,EAAE,IAAI,KAAK;AAEnF,MAAM,SAAS,MAAc,MAAc,KAAa,SAAiB;CACvE,MAAM,QAAQ,aAAa,MAAM,GAAG,KAAK,0CAA0C,IAAI;CACvF,OAAO,GAAG,KAAK,MAAM,KAAK,EAAE,GAAG,KAAK,MAAM,WAAW,IAAI,CAAC;AAC5D;AAEA,MAAM,UAAU,QAAgB,WAAW,IAAI,IAAI,GAAG,CAAC,CAAC,IAAI;;AAG5D,MAAM,aAAa,cAAmC;CACpD,KAAK,MAAM,YAAY,WACrB,IAAI,SAAS,iBAAiB,qBAC5B,OAAO,SAAS,KAAK;CAGzB,KAAK,MAAM,YAAY,WACrB,IAAI,SAAS,iBAAiB,uBAAuB,SAAS,KAAK,QAAQ,KAAA,GACzE,OAAO,SAAS,KAAK;CAGzB,OAAO;AACT;AAEA,MAAM,eAAe,UAAoB,YAA2B;CAClE,QAAQ,SAAS,cAAjB;EACE,KAAK,qBAAqB;GACxB,MAAM,EAAE,SAAS,SAAS;GAC1B,OAAO,MACL,kCAAkC,mBAAmB,IAAI,KACzD,UACA,UACA,IACF;EACF;EACA,KAAK,qBAAqB;GACxB,MAAM,EAAE,KAAK,eAAe,SAAS;GACrC,MAAM,MAAM,MACV,mEAAmE,mBAAmB,UAAU,KAChG,UACA,UACA,UACF;GACA,OAAO,QAAQ,KAAA,KAAa,QAAQ,UAChC,MACA,GAAG,IAAI,WAAW,KAAK,KAAK,OAAO,GAAG,CAAC,EAAE;EAC/C;EACA,KAAK,wBAAwB;GAC3B,MAAM,EAAE,WAAW,YAAY,iBAAiB,SAAS;GACzD,OAAO,MACL,+BAA+B,mBAAmB,SAAS,EAAE,MAAM,mBAAmB,YAAY,EAAE,WAAW,mBAAmB,UAAU,KAC5I,MACA,aACA,UACF;EACF;EACA,KAAK,4BAA4B;GAC/B,MAAM,EAAE,WAAW,SAAS;GAC5B,OAAO,mBAAmB,WAAW,QAAQ,eAAe;EAC9D;CACF;AACF;AAEA,MAAM,WAAW,EAAE,UAAqB;CACtC,IAAI,QAAQ,MACV,OAAO;CAET,MAAM,EAAE,YAAY,YAAY,WAAW,IAAI;CAC/C,MAAM,SAAS,WAAW,WAAW,MAAM,GAAG,CAAC,CAAC;CAChD,MAAM,OAAO,GAAG,WAAW,MAAM,GAAG,EAAE,CAAC,CAAC,QAAQ,KAAK,GAAG,EAAE;CAC1D,OAAO,YAAY,WAAW,OAAO,SAAS,KAAK,QAAQ,MAAM,EAAE,GAAG,WAAW,IAAI,EAAE;AACzF;AAEA,MAAM,SAAS,kBAAkB,MAAM;;AAGvC,MAAM,cAAc,MAAiB,YAAoB,UAAmB;CAC1E,MAAM,SAAS,KAAK,KAAK,MAAM,WAAW,SAAS,CAAC;CACpD,MAAM,UAAU,CACd,WAAW,MAAM,QAAQ,KAAK,MAAM,WAAW,UAAU,GAAG,EAAE,QAC9D,QAAQ,WAAW,KAAK,KAAK,EAAE,OACjC,CAAC,CAAC,KAAK,EAAE;CACT,MAAM,YAAY,eAAe,KAAK,SAAS;CAC/C,MAAM,UAAU,UAAU,SAAS;CACnC,MAAM,cAAc,YAAY,OAAO,KAAK,OAAO,KAAK,SAAS,OAAO,OAAO,CAAC;CAChF,MAAM,OAAO,UACV,KAAK,aAAa,kBAAkB,YAAY,UAAU,OAAO,EAAE,mBAAmB,CAAC,CACvF,KAAK,IAAI;CACZ,OAAO;iBACQ,SAAS,UAAU,cAAc,QAAQ,IAAI,EAAE;EAC9D,KAAK;;AAEP;AAEA,MAAa,sBAAsB,OAA6B,eAAuB,GAAG,OAAO;EAC/F,MAAM,KAAK,SAAS,WAAW,MAAM,YAAY,MAAM,WAAW,CAAC,CAAC,CAAC,CAAC,KAAK,MAAM,EAAE;;AAGrF,MAAM,WAAW,OAAO,MAAM,OAAO,OAAO;CAAE,IAAI,OAAO;CAAQ,MAAM,OAAO;AAAO,CAAC,CAAC;AACvF,MAAM,eAAe,OAAO,OAAO,EAAE,UAAU,OAAO,OAAO,CAAC;AAE9D,MAAM,UAAU,OAAe,MAAc,SAC3C,OAAO,WAAW;CAChB,KAAK,YAA8B;EACjC,MAAM,WAAW,MAAM,MAAM,GAAG,aAAa,QAAQ;GACnD,GAAG;GACH,SAAS;IACP,QAAQ;IACR,eAAe,UAAU;IACzB,gBAAgB;IAChB,wBAAwB;GAC1B;EACF,CAAC;EACD,IAAI,CAAC,SAAS,IACZ,MAAM,IAAI,MAAM,QAAQ,SAAS,QAAQ;EAE3C,OAAO,MAAM,SAAS,KAAK;CAC7B;CACA,QAAQ,UACN,IAAI,kBAAkB,EACpB,QAAQ,UAAU,KAAK,OAAO,GAAG,KAAK,WAAW,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAAE,GAC1G,CAAC;AACL,CAAC;AAEH,MAAM,UACuC,QAAW,UACrD,UACC,OAAO,oBAAoB,MAAM,CAAC,CAAC,KAAK,CAAC,CAAC,KACxC,OAAO,eAAe,IAAI,kBAAkB,EAAE,QAAQ,qBAAqB,OAAO,CAAC,CAAC,CACtF;AAEJ,MAAM,eAAe,OAAe,SAClC,OAAO,IAAI,aAAa;CACtB,KAAK,IAAI,OAAO,IAAK,QAAQ,GAAG;EAC9B,MAAM,WAAW,OAAO,OAAO,OAAO,GAAG,KAAK,qBAAqB,QAAQ,EACzE,QAAQ,MACV,CAAC,CAAC,CAAC,KAAK,OAAO,QAAQ,OAAO,UAAU,cAAc,CAAC,CAAC;EACxD,MAAM,UAAU,SAAS,MAAM,EAAE,WAAW,KAAK,WAAW,MAAM,CAAC;EACnE,IAAI,YAAY,KAAA,KAAa,SAAS,SAAS,KAC7C,OAAO;CAEX;AACF,CAAC;;AAGH,MAAa,oBAAoB,SAM/B,OAAO,IAAI,aAAa;CACtB,MAAM,EAAE,MAAM,aAAa,YAAY,UAAU;CACjD,MAAM,WAAW,UAAU,WAAW,UAAU,YAAY;CAC5D,MAAM,UAAU,OAAO,YAAY,OAAO,QAAQ;CAClD,MAAM,QAAQ,OAAO,YAAY,KAAA,IAC7B,OAAO,OAAO,UAAU;EAAE,QAAQ;EAAQ,MAAM,KAAK,UAAU,EAAE,KAAK,CAAC;CAAE,CAAC,IAC1E,OAAO,OAAO,UAAU,WAAW,mBAAmB,QAAQ,MAAM;EAClE,QAAQ;EACR,MAAM,KAAK,UAAU,EAAE,KAAK,CAAC;CAC/B,CAAC;CAEL,QAAO,OADc,OAAO,cAAc,SAAS,CAAC,CAAC,KAAK,EAAA,CAC7C;AACf,CAAC;;AAGH,MAAa,qBAAqB,OAAO,IAAI,aAAa;CACxD,MAAM,EAAE,aAAa,eAAe,OAAO,UAAU;EAAE,KAAK,QAAQ,IAAI;EAAG,KAAK,QAAQ;CAAI,CAAC;CAC7F,IAAI,gBAAgB,MAClB,OAAO,OAAO,IAAI,kBAAkB,EAAE,QAAQ,+BAA+B,CAAC;CAEhF,MAAM,QAAQ,QAAQ,IAAI;CAC1B,IAAI,UAAU,KAAA,KAAa,UAAU,IACnC,OAAO,OAAO,IAAI,kBAAkB,EAAE,QAAQ,0BAA0B,CAAC;CAE3E,MAAM,QAAQ,OAAO,eAAe;EAAE;EAAY,OAAO,MAAM;CAAc,CAAC;CAC9E,OAAO,OAAO,iBAAiB;EAC7B;EACA;EACA;EACA,MAAM,mBAAmB,OAAO,UAAU;CAC5C,CAAC;AACH,CAAC;;;ACnSD,MAAM,EAAE,aAAa,WAAW,UAAU;CACxC,kBAAkB;CAClB,SAAS;EACP,MAAM,EAAE,MAAM,SAAS;EACvB,KAAK,EAAE,MAAM,SAAS;EACtB,iBAAiB,EAAE,MAAM,SAAS;EAClC,cAAc,EAAE,MAAM,SAAS;CACjC;AACF,CAAC;AACD,MAAM,EAAE,MAAM,KAAK,iBAAiB,cAAc,cAAc,cAAc;AAC9E,MAAM,QAAQ;;;AAId,MAAM,aAAa,QACjB,OAAO,IAAI,aAAa;CACtB,MAAM,CAAC,SAAS,GAAG,UAAU,OAAO,aAAa,QAAQ,IAAI,CAAC;CAC9D,MAAM,cAAc,OAAO,0BAA0B;EACnD,GAAG;EACH,SAAS,MAAM,UACb,aAAa,SAAS,CAAC,GAAG,QAAQ,GAAG,IAAI,GAAG;GAC1C,UAAU;GACV;GACA,OAAO;IAAC;IAAQ;IAAQ;GAAS;EACnC,CAAC,CAAC,CAAC,KAAK;CACZ,CAAC;CACD,QAAQ,IACN,gBAAgB,KAAA,IACZ,wBACA,6BAA6B,YAAY,EAC/C;AACF,CAAC,CAAC,CAAC,KAAK,OAAO,QAAQ,kBAAkB,CAAC,CAAC,KAAK,MAAM,QAAQ,WAAW,CAAC,CAAC,CAAC,CAAC;AAE/E,MAAM,gBAAgB,YACpB,mBAAmB,KACjB,OAAO,KAAK,QAAQ,OAAO,WAAW,QAAQ,IAAI,GAAG,CAAC,CAAC,GACvD,OAAO,QAAQ,MAAM,OAAO,CAAC,CAAC,KAAK,MAAM,QAAQ,aAAa,KAAK,CAAC,CAAC,CACvE;AAEF,MAAM,aAAa,YAAY,KAAK,GAAG;AACvC,MAAM,UACJ,eAAe,kBACX,aAAa,SAAS,KAAA,IAAY,CAAC,IAAI,EAAE,KAAK,CAAC,IAC/C,eAAe,gBACb,SAAS,KAAA,KACT,QAAQ,KAAA,KACR,iBAAiB,KAAA,IACjB,UAAU;CAAE;CAAM;CAAK;CAAc,GAAI,cAAc,KAAA,IAAY,CAAC,IAAI,EAAE,UAAU;AAAG,CAAC,IACxF,KAAA;AACR,IAAI,YAAY,KAAA,GAAW;CACzB,QAAQ,MAAM,KAAK;CACnB,QAAQ,KAAK,CAAC;AAChB;AAEA,MAAM,OAAO,WAAW,OAAO,CAAC,CAAC,OAAO,UAAmB;CACzD,QAAQ,MAAM,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,CAAC;CACpE,QAAQ,KAAK,CAAC;AAChB,CAAC"}
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(\"&\", \"&amp;\")\n .replaceAll(\"<\", \"&lt;\")\n .replaceAll(\">\", \"&gt;\")\n .replaceAll('\"', \"&quot;\");\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 SharedHubClientProps {
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
- interface SharedHubClientAttributes {
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" | "off" | "suspended";
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: string;
523
- readonly clientId: string;
535
+ readonly issuer: Schema.String;
536
+ readonly clientId: Schema.String;
524
537
  /** Returned only at registration. */
525
- readonly clientSecret: Redacted.Redacted<string>;
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: Redacted.Redacted<string>;
528
- readonly registrationClientUri: string;
529
- readonly deploymentType: "production" | "preview";
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: string;
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").ResourceClass<SharedHubClient>;
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. */
@@ -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":";;;;;;;;;;;;;;;;UA6BiB;;WAEN,aAAa,SAAS;;WAEtB;;UAGM;;WAEN,aAAa,OAAO,OAAO;;WAE3B,gBAAgB,OAAO;;WAEvB,WAAW,OAAO,OAAO,SAAS;;;qBAGhC,oBAAoB;;qBAKpB;;qBAGA;;;;qBAOA,yBAAyB;;;;;;;;;qBAiCzB,kBAAiB,MAAM,MAAM;;qBA0B7B,aAAc;WAChB,cAAc,SAAS;WACvB,YAAY,SAAS;MAC5B,MAAM,MAAM;;;KC3GJ,mBAAmB,kBAAkB;KACrC,mBAAmB,kBAAkB;;UAGhC,YAAY;WAClB,OAAO;WACP;WACA,UAAU;;;;;;;;;;;;;qBAQR,uBAAuB;UAuBnB;;WAEN,UAAU,GACjB,mBACA,MAAM,QAAQ,qBAAqB,QAAQ,YAAY,QACpD,OAAO,OAAO,GAAG;;WAEb,cACP,mBACA,MAAM,QAAQ,qBAAqB,QAAQ,0BACxC,OAAO,aAAa;;WAEhB,oBAAoB,GAC3B,mBACA,6BACA,WAAW,SAAS,kBACpB,MAAM,QAAQ,qBAAqB,QAAQ,YAAY,QACpD,OAAO,OAAO,GAAG;;;;;;;;WAQb,gBACP,sCACG,OAAO,eAAe;;;;;;;;WAQlB,oBAAoB,mBAAmB,OAAO;;;;;;;WAO9C,mBAAmB,OAAO;;;;;WAK1B,YAAY,OAAO,SAAS,qBAAqB;;;qBAM/C,sBAAsB;;;;qBC/FtB,kBAAkB;;;;;;qBAOlB,YAAa,cAAa,MAAM,MAAM,iBAA2B,MAAA,MAAA,8CAAA,mCAAA,oDAAA;;;;KC6BlE,oBAAoB,SAAS;;;;;;KAO7B;;WAIG;;;;;;;;WAQA;;;;;;;WAOA;;;;;;qBAkCF,0BAA0B;MAIxB;;;;;;;qBAMF,0BAA0B;MAOxB;;;;;;;;;qBAMF,uBAAuB;MAMrB;;;;;;;qBAQF,2BAA2B;MAIzB;;;;;;;qBAMF,oCAAoC;MAIlC;;;;;;qBAMF,kCAAkC;MAIhC;;;qBAuCF,oBACX;EACG;EAAa;;;;UC3HR;;WAEC;aAAoB;;;;;;;;WAOpB;;;;;;WAMA,SAAS;;WAET,MAAM,SAAS,eAAe;;KAG7B,mBACP;;WAEU;MAEV;;WAEU;;;;;WAKA;;;UAIL;WACC;WACA,MAAM;;WAEN;;WAEA;;WAEA;;WAEA;;WAEA;;UAGM,6BAA6B;;WAEnC;;WAEA;;YAGC,aAAa,8BAEvB,iBACA,sBACA,kBACA;;KAQU,kBAAkB,+BAA8B,KACxD,UAAU,kBACR,WAAW;;;;;qBAWJ,wBAAwB;MAGtB;;;;;;;;;;;;qBAeF,cAAU,YACV,OAAS,oBAAe,OAAA,OAAA,mBAAA;;;;;;EAYvB,MAAA,YAAM;IAAc;IAAgB;QAAgB,OAAA,OAAA;;;;;;;;;;qBAMrD,iCAAiC;MAU/B;;;;;;qBAMF,qCAAqC;MAInC;;;;;;;;;qBAMF,4BAA4B;MAS1B;;;;;UC1OE;WACN;WACA;WACA;;;;;;;;;qBAUE,gBAAiB;EAC5B,YAAY;EACZ;EACA,WAAW;MACZ,OAAA,yBAAA;;;;KCWW,mBAAmB;KAEnB;;WAGG;;WAEA;;WAEA,mBAAmB;;WAEnB;;;WAIA;;WAEA;;UAGE;WACN;WACA;WACA;WACA;WACA;WACA;;YAGC,UAAU,2BAA2B,cAAc,0BAA0B;qBAC5E,2BAAO,cAAA;;;;;;;qBAGP,0BAA0B;MAIxB;;;;;;;;;;;;qBASF,gCAAgC;MAS9B;;;;;;;;qBAOF,yBAAyB;MAKvB;;;;;;qBAMF,+BAA+B;MAI7B;;;;;;;qBAMF,wBAAwB;MAItB;;;;;qBC1FF;KAED;UAEK;;WAEN;;WAEA;;WAEA;WACA,gBAAgB;;WAEhB;;WAEA;;UAGM;;WAEN;WACA;WACA,gBAAgB;;WAEhB;WACA;;WAEA,cAAc,SAAS;;YAGtB,YAAY,+BAEtB,gBACA,4BAEA;qBAEW,6BAAS,cAAA;;;;;;qBAGT,wBAAwB;MAItB;;;;;qBC9DF;;UAMI;WACN;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;WACA;;WAEA;;;;;qBAGE,uBAAuB;MAGrB;;;UAME;WACN;WACA,KAAK,SAAS;;;;;;;;qBAuIZ,YAAa,OAAO,mBAAiB,OAAO,OAAO,WAAW;;;;;;;qBAiB9D,gBAAiB,iBAAiB,QAAQ;;;UC7KtC;;WAEN;;;;;;WAMA;WACA;;WAEA;;UAGM;;WAEN;;WAEA;WACA;;WAEA,cAAc,SAAS;;WAEvB,yBAAyB,SAAS;WAClC;WACA;;WAEA;;YAGC,kBAAkB,qCAE5B,sBACA,kCAEA;qBAEW,mCAAe,cAAA;;;;qBCsBf;;;;;;;;;qBA8ZA,QACX;WAAoB;MACnB,MAAM,MAAM,cAAc,oBAAoB"}
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
- const SharedHubClient = Resource("IdentityHub.SharedClient");
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
- const requestNewClient = (props, requestUrl, requestToken) => Effect.gen(function* () {
116
- const token = yield* readOidcToken(requestUrl, requestToken);
117
- const { body, status } = yield* send("The identity hub", `${process.env["HUB_URL"] ?? "https://auth.samebase.com"}/hub-clients`, token, {
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 `saved`, or the off values. `saved` holds no off values. */
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
- status: "off",
144
- issuer: "http://127.0.0.1:1",
145
- clientId: "no-hub",
146
- clientSecret: Redacted.make("no-hub"),
147
- registrationAccessToken: Redacted.make(""),
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 requestNewClient(props, requestUrl, requestToken).pipe(Effect.catchTag("SharedHubClientFailed", (error) => Effect.logWarning(`${label}: ${error.reason}. The next deploy asks again.`).pipe(Effect.as(off))));
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?.status === "off" ? void 0 : 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":";;;;;;;;;;;;;;;;;;;AAqBA,MAAa,iBAAiB,YAK5B,OAAO,IAAI,aAAa;CACtB,IAAI,QAAQ,cAAc,IACxB,OAAO,OAAO,OAAO,oBAAI,IAAI,MAAM,8BAA8B,CAAC;CAEpE,MAAM,YAAY,OAAO,QAAQ,SAAS;CAC1C,MAAM,MAAM,oBAAoB,KAAK,UAAU,SAAS;CAExD,MAAM,YAAW,OADI,MAAA,CACE,SAAS,QAAQ,WAAW,IAAI,EAAE,MAAM,YAAY,QAAQ,QAAQ,GAAG;CAE9F,IAAI,aAAa,KAAA,GAAW;EAC1B,MAAM,WAAW,OAAO,kBAAkB,OAAO,OAAO,EAAE,MAAM,OAAO,OAAO,CAAC,CAAC,CAAC,CAC/E,SAAS,IACX;EACA,IAAI,SAAS,SAAS,QAAQ,WAC5B,OAAO,OAAO,OAAO,oBACnB,IAAI,MACF,qBAAqB,KAAK,UAAU,SAAS,EAAE,uBAAuB,SAAS,KAAK,EACtF,CACF;EAEF;CACF;CACA,OAAO,QAAQ,WAAW,KAAK,KAAK;EAClC,MAAM;EACN,MAAM,QAAQ;EACd;CACF,CAAC;AACH,CAAC;;;;ACzBH,MAAM,qBAAqB;AAuC3B,MAAa,kBAAkB,SAA0B,0BAA0B;;AAGnF,IAAM,wBAAN,cAAoC,OAAO,YAAmC,CAAC,CAC7E,yBACA,EAAE,QAAQ,OAAO,OAAO,CAC1B,CAAC,CAAC,CAAC;AAEH,MAAM,YAAY,OAAO,OAAO,EAAE,OAAO,OAAO,eAAe,CAAC;;AAGhE,MAAM,YAAY,OAAO,OAAO;CAC9B,QAAQ,OAAO;CACf,WAAW,OAAO;CAClB,eAAe,OAAO;CACtB,2BAA2B,OAAO;CAClC,yBAAyB,OAAO;CAChC,cAAc,OAAO;;CAErB,OAAO,OAAO;AAChB,CAAC;;AAGD,MAAM,WAA6C;CACjD,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;AACP;;AAGA,MAAM,8BAAc,IAAI,IAAI;CAC1B,CAAC,UAAU,yDAAyD;CACpE,CAAC,mBAAmB,0EAA0E;CAC9F,CAAC,WAAW,+CAA+C;CAC3D,CAAC,aAAa,4CAA4C;CAC1D,CAAC,iBAAiB,+DAA+D;CACjF,CAAC,oBAAoB,2DAA2D;CAChF,CAAC,aAAa,2DAA2D;CACzE,CAAC,oBAAoB,iEAAiE;CACtF,CACE,6BACA,2EACF;CACA,CAAC,6BAA6B,gEAAgE;CAC9F,CAAC,uBAAuB,kEAAkE;AAC5F,CAAC;;AAGD,MAAM,+BAAe,IAAI,IAAI;CAC3B,CAAC,cAAc,sEAAsE;CACrF,CAAC,SAAS,kEAAkE;CAC5E,CAAC,cAAc,sDAAsD;AACvE,CAAC;AAED,MAAM,UAAU,OAAO,OAAO,EAAE,QAAQ,OAAO,OAAO,CAAC;AACvD,MAAM,cAAc,OAAO,OAAO,EAAE,QAAQ,OAAO,OAAO,CAAC;;AAG3D,MAAM,aAAa,QAAgB,SAAkB;CAWnD,QATE,WAAW,MACP,YAAY,IACV,OAAO,eAAe,OAAO,oBAAoB,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,EAAE,UAAU,EAC9E,IACA,WAAW,MACT,aAAa,IACX,OAAO,eAAe,OAAO,oBAAoB,WAAW,CAAC,CAAC,IAAI,CAAC,CAAC,EAAE,UAAU,EAClF,IACA,KAAA,MACQ,SAAS,WAAW;AACtC;AAEA,MAAM,QAAQ,KAAa,KAAa,OAAe,SACrD,OAAO,WAAW;CAChB,KAAK,YAAY;EACf,MAAM,WAAW,MAAM,MAAM,KAAK;GAChC,GAAG;GACH,SAAS;IAAE,eAAe,UAAU;IAAS,gBAAgB;GAAmB;GAChF,QAAQ,YAAY,QAAQ,kBAAkB;EAChD,CAAC;EACD,MAAM,OAAgB,MAAM,SAAS,KAAK,CAAC,CAAC,YAAY,KAAA,CAAS;EACjE,OAAO;GAAE,QAAQ,SAAS;GAAQ;EAAK;CACzC;CACA,aAAa,IAAI,sBAAsB,EAAE,QAAQ,GAAG,IAAI,iBAAiB,CAAC;AAC5E,CAAC;AAEH,MAAM,UACuC,QAAW,SACrD,UACC,OAAO,oBAAoB,MAAM,CAAC,CAAC,KAAK,CAAC,CAAC,KACxC,OAAO,eAAe,IAAI,sBAAsB,EAAE,QAAQ,GAAG,IAAI,oBAAoB,CAAC,CAAC,CACzF;;AAGJ,MAAM,iBAAiB,YAAoB,iBACzC,OAAO,IAAI,aAAa;CACtB,MAAM,EAAE,MAAM,WAAW,OAAO,KAC9B,UACA,GAAG,WAAW,qBACd,cACA,EAAE,QAAQ,MAAM,CAClB;CACA,IAAI,WAAW,KACb,OAAO,OAAO,IAAI,sBAAsB,EACtC,QAAQ,mCAAmC,OAAO,GACpD,CAAC;CAEH,QAAQ,OAAO,OAAO,WAAW,QAAQ,CAAC,CAAC,IAAI,EAAA,CAAG;AACpD,CAAC;AAEH,MAAM,oBAAoB,OAA6B,YAAoB,iBACzE,OAAO,IAAI,aAAa;CACtB,MAAM,QAAQ,OAAO,cAAc,YAAY,YAAY;CAC3D,MAAM,EAAE,MAAM,WAAW,OAAO,KAC9B,oBACA,GAAG,QAAQ,IAAI,cAAc,4BAA4B,eACzD,OACA;EACE,QAAQ;EACR,MAAM,KAAK,UAAU;GACnB,KAAK,MAAM;GACX,aAAa,MAAM;GACnB,iBAAiB,MAAM,mBAAmB;EAC5C,CAAC;CACH,CACF;CACA,IAAI,WAAW,KACb,OAAO,OAAO,IAAI,sBAAsB,EACtC,QAAQ,GAAG,UAAU,QAAQ,IAAI,EAAE,SAAS,OAAO,GACrD,CAAC;CAEH,MAAM,SAAS,OAAO,OAAO,WAAW,kBAAkB,CAAC,CAAC,IAAI;CAChE,OAAO,OAAO,QACZ,2BAA2B,MAAM,IAAI,MAAM,OAAO,aAAa,0DAA0D,OAAO,MAAM,EACxI;CACA,OAAO;EACL,QAAQ;EACR,QAAQ,OAAO;EACf,UAAU,OAAO;EACjB,cAAc,SAAS,KAAK,OAAO,aAAa;EAChD,yBAAyB,SAAS,KAAK,OAAO,yBAAyB;EACvE,uBAAuB,OAAO;EAC9B,gBAAgB,MAAM;EACtB,aAAa,OAAO;CACtB;AACF,CAAC;;AAGH,MAAM,aAAa,UAAgC;CACjD,MAAM,QAAQ,2BAA2B,MAAM,IAAI,MAAM,MAAM,YAAY;CAC3E,MAAM,MAAiC;EACrC,QAAQ;EACR,QAAQ;EACR,UAAU;EACV,cAAc,SAAS,KAAK,QAAQ;EACpC,yBAAyB,SAAS,KAAK,EAAE;EACzC,uBAAuB;EACvB,gBAAgB,MAAM;EACtB,aAAa;CACf;CACA,MAAM,aAAa,QAAQ,IAAI;CAC/B,MAAM,eAAe,QAAQ,IAAI;CAGjC,IAAI,CAAC,cAAc,CAAC,cAClB,QACE,QAAQ,IAAI,sBAAsB,SAC9B,OAAO,WAAW,GAAG,MAAM,sDAAsD,IACjF,OAAO,QAAQ,GAAG,MAAM,qCAAqC,EAAA,CACjE,KAAK,OAAO,GAAG,GAAG,CAAC;CAEvB,OAAO,iBAAiB,OAAO,YAAY,YAAY,CAAC,CAAC,KACvD,OAAO,SAAS,0BAA0B,UACxC,OAAO,WAAW,GAAG,MAAM,IAAI,MAAM,OAAO,8BAA8B,CAAC,CAAC,KAC1E,OAAO,GAAG,GAAG,CACf,CACF,CACF;AACF;;AAGA,MAAM,SAAS,UACb,OAAO,IAAI,aAAa;CACtB,MAAM,EAAE,WAAW,OAAO,KACxB,oBACA,MAAM,uBACN,SAAS,MAAM,MAAM,uBAAuB,GAC5C,EAAE,QAAQ,MAAM,CAClB;CACA,QAAQ,QAAR;EACE,KAAK,KACH,OAAO;GAAE,GAAG;GAAO,QAAQ;EAAc;EAC3C,KAAK,KACH;EACF,KAAK;GACH,OAAO,OAAO,WACZ,2DAA2D,MAAM,YAAY,uDAC/E;GACA,OAAO;IAAE,GAAG;IAAO,QAAQ;GAAqB;EAClD,SACE,OAAO,OAAO,IAAI,sBAAsB,EACtC,QAAQ,kCAAkC,SAC5C,CAAC;CACL;AACF,CAAC,CAAC,CAAC,KACD,OAAO,SAAS,0BAA0B,UACxC,OAAO,WACL,2BAA2B,MAAM,YAAY,2BAA2B,MAAM,OAAO,EACvF,CAAC,CAAC,KAAK,OAAO,GAAG,KAAK,CAAC,CACzB,CACF;AAEF,MAAa,gCACX,SAAS,QAAQ,iBAAiB;CAGhC,OAAO,EAAE,WACP,OAAO,QAAQ,WAAW,IAAI,IAAK,EAAE,QAAQ,SAAS,IAAc,KAAA,CAAS;CAE/E,OAAO,EAAE,aAAa,OAAO,QAAQ,MAAM;CAE3C,WAAW,OAAO,GAAG,WAAW,EAAE,MAAM,UAAU;EAChD,MAAM,QAAQ,QAAQ,WAAW,QAAQ,KAAA,IAAY;EAErD,QADgB,OAAO,gBAAgB,KAAK,cAAc,OAAO,MAAM,KAAK,IAAI,KAAA,OAC7D,OAAO,UAAU,IAAI;CAC1C,CAAC;CAID,SAAS,EAAE,aACT,OAAO,mBAAmB,gBAAgB,OAAO,WAAW,QACxD,OAAO,OACP,OAAO,IAAI,aAAa;EACtB,MAAM,EAAE,WAAW,OAAO,KACxB,oBACA,OAAO,uBACP,SAAS,MAAM,OAAO,uBAAuB,GAC7C,EAAE,QAAQ,SAAS,CACrB;EAEA,IAAI,WAAW,OAAO,WAAW,KAC/B,OAAO,OAAO,IAAI,sBAAsB,EACtC,QAAQ,kCAAkC,SAC5C,CAAC;CAEL,CAAC,CAAC,CAAC,KACD,OAAO,SAAS,0BAA0B,UACxC,OAAO,WACL,yCAAyC,OAAO,YAAY,4CAA4C,MAAM,OAAO,EACvH,CACF,CACF;AACR,CAAC;;;ACvTH,IAAa,YAAb,cAA+B,SAAS,mBAA8B,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC;;;;;;AAOnF,MAAa,aAAa,cAAwC,WAAW,MAC3E,MAAM,OACJ,WACA,SAAS,WAAW;CAAC;CAAS;CAAoB;CAAW;AAAe,CAAC,CAC/E,CAAC,CAAC,KACA,MAAM,QACJ,MAAM,SACJ,gBAAgB,GAChB,mBAAmB,GACnB,kBAAkB,GAClB,wBAAwB,CAC1B,CACF,GACA,MAAM,QAAQ,kBAAkB,CAAC,GACjC,MAAM,QAAQ,WAAW,GACzB,MAAM,KACR"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samebase/alchemy-convex",
3
- "version": "0.25.1",
3
+ "version": "0.27.0",
4
4
  "description": "The Convex plane for Alchemy v2: projects, deployments, environment variables, and the push of the functions",
5
5
  "keywords": [
6
6
  "alchemy",
@@ -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
- // The run asks the hub for a new client with a GitHub OIDC token, so the stack needs no key and no
5
- // Samebase account. The token names the repository. The hub reads `/samebase-verification.json` at
6
- // the origin of the return address, which must name the same repository, and registers the client on
7
- // that address. The answer holds the client's own registration access token (RFC 7591), and each
8
- // later deploy reads the client on the hub with that token (RFC 7592). The hub keeps only hashes, so
9
- // the state is the only record of the secret and the token.
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 request
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
- export interface SharedHubClientProps {
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
- export interface SharedHubClientAttributes {
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
- readonly status: "on" | "off" | "suspended";
77
+ status: Schema.Literals(["on", "off", "suspended"]),
48
78
  /** The issuer of the hub, which the deployment names to sign in. */
49
- readonly issuer: string;
50
- readonly clientId: string;
79
+ issuer: Schema.String,
80
+ clientId: Schema.String,
51
81
  /** Returned only at registration. */
52
- readonly clientSecret: Redacted.Redacted<string>;
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
- readonly registrationAccessToken: Redacted.Redacted<string>;
55
- readonly registrationClientUri: string;
56
- readonly deploymentType: "production" | "preview";
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
- readonly redirectUri: string;
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
- export type SharedHubClient = Resource<
62
- "IdentityHub.SharedClient",
63
- SharedHubClientProps,
64
- SharedHubClientAttributes,
65
- never,
66
- Providers
67
- >;
68
- export const SharedHubClient = Resource<SharedHubClient>("IdentityHub.SharedClient");
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
- const requestNewClient = (props: SharedHubClientProps, requestUrl: string, requestToken: string) =>
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
- token,
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
- return yield* new SharedHubClientFailed({
199
- reason: `${refusalOf(status, body)} (HTTP ${status})`,
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 `saved`, or the off values. `saved` holds no off values. */
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: SharedHubClientAttributes = {
222
- status: "off",
223
- issuer: "http://127.0.0.1:1",
224
- clientId: "no-hub",
225
- clientSecret: Redacted.make("no-hub"),
226
- registrationAccessToken: Redacted.make(""),
227
- registrationClientUri: "",
228
- deploymentType: props.deploymentType,
229
- redirectUri: "",
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 requestNewClient(props, requestUrl, requestToken).pipe(
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 = output?.status === "off" ? undefined : output;
294
- const renewed = saved?.redirectUri === news.redirectUri ? yield* renew(saved) : undefined;
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 client cleans it up.
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