From d0e4b1c6eed08b49e67566d2d03abb9fbc9cf380 Mon Sep 17 00:00:00 2001 From: RedStar Date: Sun, 8 Nov 2026 21:54:01 +0100 Subject: [PATCH 1/4] feat(database): store GitHub App installations and organization repositories The managed cloud has no checkout on disk, so the repository table cannot describe what a customer connects. Add forge_installation (one provider installation, attached to exactly one organization) and organization_repository (the repositories an organization selected through it), with the access functions and a checked-in migration. The onboarding design that these tables serve is in docs/plans. --- ...26-11-08-onboarding-installation-design.md | 164 + .../database/drizzle/0007_silent_mordo.sql | 34 + .../database/drizzle/meta/0007_snapshot.json | 3639 +++++++++++++++++ packages/database/drizzle/meta/_journal.json | 7 + packages/database/src/client.test.ts | 2 + packages/database/src/index.ts | 17 + packages/database/src/installations.test.ts | 184 + packages/database/src/installations.ts | 305 ++ packages/database/src/schema/index.ts | 4 + packages/database/src/schema/installation.ts | 88 + 10 files changed, 4444 insertions(+) create mode 100644 docs/plans/2026-11-08-onboarding-installation-design.md create mode 100644 packages/database/drizzle/0007_silent_mordo.sql create mode 100644 packages/database/drizzle/meta/0007_snapshot.json create mode 100644 packages/database/src/installations.test.ts create mode 100644 packages/database/src/installations.ts create mode 100644 packages/database/src/schema/installation.ts diff --git a/docs/plans/2026-11-08-onboarding-installation-design.md b/docs/plans/2026-11-08-onboarding-installation-design.md new file mode 100644 index 0000000..ba5ac25 --- /dev/null +++ b/docs/plans/2026-11-08-onboarding-installation-design.md @@ -0,0 +1,164 @@ +# Onboarding: organization, GitHub App installation, repository selection + +Status: proposed. Decisions marked **(owner)** were made by the project owner on 2026-11-08; the rest +are the author's calls and are open to change in review. + +## Why + +A person who signs in to the managed cloud today lands on an empty dashboard. Nothing tells them to +create an organization, and nothing connects their code. The cloud also has no checkout on disk, so +the existing `repository` table (an allow-list of `checkout_path` values, global to the deployment) +cannot describe what a customer wants reviewed. + +The goal is one guided path: sign in, create an organization, install the Code Zero GitHub App, and +choose which of the repositories the installation can see Code Zero should watch. + +## Flow + +1. A signed-in user with no organization is sent to `/onboarding`. **(owner: every registered user, + wizard required)** +2. **Organization.** They name it. The slug is derived and editable. They become its owner. +3. **Install.** The wizard sends them to the GitHub App's install page with a signed `state`. GitHub + returns to `/onboarding/github/callback` with `installation_id` and a user authorization `code`. +4. **Choose repositories.** The wizard lists the repositories the installation can see. They tick the + ones to watch and pick `observe` or `suggest` (never a writable mode). +5. Done: the dashboard shows the organization's repositories. + +A user who already has an organization is not redirected. `/onboarding` stays reachable to add +another installation or more repositories. + +## Goals + +- One path from first sign-in to a watched repository, with no operator involvement. +- An installation belongs to exactly one organization, and only someone who controls it on GitHub + can attach it. +- Self-hosted behavior is unchanged: the `repository` table, `checkout_path`, and the poller stay as + they are. + +## Non-goals + +- Creating the GitHub App itself (a one-time owner action, see below). +- GitLab, Bitbucket, Gitea, Forgejo, or Azure DevOps onboarding. The tables carry a `provider` column + so they can follow; the wizard is GitHub only. +- Running reviews. Selecting a repository records intent; execution on the cloud is separate work. +- Billing and plan limits. + +## Anti-goals + +- Never store an installation token or the app private key in the database. The private key is a + Worker secret; installation tokens are minted per request by the `forges` provider. +- Never let a user attach an installation they cannot prove they control, even if they know its id. + +## Data model **(owner: organization-scoped, no checkout)** + +Two new tables in `packages/database`, with a checked-in migration. `repository` is untouched. + +`forge_installation` + +| column | notes | +| ------------------------------- | ----------------------------------------------------------- | +| `id` | text primary key, `crypto.randomUUID()` in application code | +| `organization_id` | references `organization.id`, cascade | +| `provider` | text, default `github` | +| `external_id` | the provider's installation id, as text | +| `account_login`, `account_type` | the GitHub user or organization the app is installed on | +| `created_by_user_id` | references `user.id`, set null on delete | +| `suspended_at` | set from the `suspend` webhook, null while active | +| timestamps | `timestampColumns` | + +Unique on `(provider, external_id)`: one installation, one organization. + +`organization_repository` + +| column | notes | +| --------------------------- | ------------------------------------------------------------------------------- | +| `id` | text primary key | +| `organization_id` | references `organization.id`, cascade | +| `installation_id` | references `forge_installation.id`, cascade | +| `provider`, `owner`, `name` | coordinates on the provider | +| `external_id` | the provider's repository id, so a rename does not lose the row | +| `mode` | `observe` or `suggest`, checked where rows are written, as `repository.mode` is | +| timestamps | `timestampColumns` | + +Unique on `(organization_id, provider, owner, name)`. + +## API + +New procedures in `packages/api`, behind the existing `authenticated` middleware, session principals +only. Each checks organization membership itself (the active organization on a session is a claim, +not a grant), the way `settings.organization.*` does. + +| procedure | role needed | does | +| ------------------------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `installations.start` | owner or admin | returns the GitHub App install URL carrying a signed `state` bound to the user and organization | +| `installations.complete` | owner or admin | verifies `state`, verifies control of `installation_id`, stores the row | +| `installations.list` | member | the organization's installations | +| `installations.repositories` | owner or admin | the repositories the installation can see, from the provider, never cached in the database | +| `repositories.organization.save` / `.remove` / `.list` | owner or admin to write, member to read | the selected repositories | + +`packages/api` defines the contracts structurally, as it does for `RepositoryAdmin`: + +```ts +interface InstallationProvider { + installUrl(state: string): string; + /** Proves the user controls the installation and returns its account. */ + verify(input: { installationId: string; userCode: string }): Promise; + repositories(installationId: string): Promise; +} +``` + +`apps/web` implements it over `@code-zero/forge`, which `apps/web` does not import yet; adding that +dependency is the first step of wiring it in (see `packages/forge` in `CLAUDE.md`). Creating the +organization uses the Better Auth organization client, as `useOrganizations()` already does. + +## Proving control of an installation + +An `installation_id` arrives in a query string, so it is attacker-controlled. Claiming it without a +check would let anyone attach another customer's installation by guessing an id. + +`installations.complete` therefore needs the user authorization `code` GitHub sends back when the +app is configured to request user authorization during installation. It exchanges the code for a user +token with the app's client id and secret, lists `GET /user/installations`, and accepts the id only if +it appears there. `state` is signed with a Worker secret, expires in ten minutes, and is single use, +which closes the cross-site request forgery path. + +Open item: from `app.code-zero.dev` the Worker currently gets HTTP 525 when it calls +`https://github.com/login/oauth/access_token`, the same endpoint this exchange needs. The same Worker +reached through `workers.dev` works. Until that is resolved, the install step cannot complete on the +custom domain. The code can be built and tested against a fake provider meanwhile. + +## GitHub App **(owner: to be created)** + +The owner creates the app once, from a manifest this repository will carry. It needs: + +- Repository permissions: Metadata (read), Contents (read), Pull requests (read and write), Checks + (read and write), Issues (read). The writable ones can be dropped to read-only for an `observe` + only launch. +- Subscribed events: `installation`, `installation_repositories`, `pull_request`. +- Request user authorization during installation, with the callback + `https://app.code-zero.dev/onboarding/github/callback`. +- A webhook at `https://app.code-zero.dev/webhooks/github`, with a secret. + +Worker configuration, all secrets: `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_ID`, +`GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`, `ONBOARDING_STATE_SECRET`. Variable: +`GITHUB_APP_SLUG`. Organization policy: `AUTH_ENABLE_ORGANIZATIONS=true` and +`AUTH_ALLOW_ORGANIZATION_CREATION=true` on the cloud edition. + +## Slices + +Each slice is its own pull request and is useful on its own. + +1. **Schema and API.** Tables, migration, the `InstallationProvider` contract, the procedures, and + unit tests against a fake provider and the in-memory store. No UI, no secrets. +2. **Provider.** `apps/web` depends on `@code-zero/forge` and implements `InstallationProvider`, plus + the signed state and the `installation` webhook handler that keeps `suspended_at` and removed + repositories current. +3. **Wizard.** `/onboarding` and its callback route, the redirect for users without an organization, + the `onboarding` message file in `en` and `it`, and the organization policy variables. +4. **Manifest and docs.** The GitHub App manifest, the deployment guide, and the checklist of Worker + secrets. + +## Left to the builder + +Component structure and styling inside the existing design system, exact copy, pagination of the +repository list (`reposPage` is paged), and how the wizard resumes after a reload. diff --git a/packages/database/drizzle/0007_silent_mordo.sql b/packages/database/drizzle/0007_silent_mordo.sql new file mode 100644 index 0000000..ba323f5 --- /dev/null +++ b/packages/database/drizzle/0007_silent_mordo.sql @@ -0,0 +1,34 @@ +CREATE TABLE "forge_installation" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "provider" text DEFAULT 'github' NOT NULL, + "external_id" text NOT NULL, + "account_login" text NOT NULL, + "account_type" text NOT NULL, + "created_by_user_id" text, + "suspended_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "organization_repository" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "installation_id" text NOT NULL, + "provider" text DEFAULT 'github' NOT NULL, + "external_id" text NOT NULL, + "owner" text NOT NULL, + "name" text NOT NULL, + "mode" text DEFAULT 'observe' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "forge_installation" ADD CONSTRAINT "forge_installation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "forge_installation" ADD CONSTRAINT "forge_installation_created_by_user_id_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_repository" ADD CONSTRAINT "organization_repository_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_repository" ADD CONSTRAINT "organization_repository_installation_id_forge_installation_id_fk" FOREIGN KEY ("installation_id") REFERENCES "public"."forge_installation"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "forge_installation_provider_external_unique" ON "forge_installation" USING btree ("provider","external_id");--> statement-breakpoint +CREATE INDEX "forge_installation_organization_idx" ON "forge_installation" USING btree ("organization_id");--> statement-breakpoint +CREATE UNIQUE INDEX "organization_repository_coordinates_unique" ON "organization_repository" USING btree ("organization_id","provider","owner","name");--> statement-breakpoint +CREATE INDEX "organization_repository_installation_idx" ON "organization_repository" USING btree ("installation_id"); \ No newline at end of file diff --git a/packages/database/drizzle/meta/0007_snapshot.json b/packages/database/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..b7103ce --- /dev/null +++ b/packages/database/drizzle/meta/0007_snapshot.json @@ -0,0 +1,3639 @@ +{ + "id": "2f568e95-b94b-4601-87ea-ebacb1485e37", + "prevId": "4c305f87-c67c-412c-ae9e-acfdbc7b25c0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_key_config_id_idx": { + "name": "api_key_config_id_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_reference_id_idx": { + "name": "api_key_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_unique": { + "name": "api_key_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_setting": { + "name": "deployment_setting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "deployment_setting_singleton": { + "name": "deployment_setting_singleton", + "value": "\"deployment_setting\".\"id\" = 'deployment'" + } + }, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_code_device_code_unique": { + "name": "device_code_device_code_unique", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_user_code_unique": { + "name": "device_code_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_user_id_idx": { + "name": "device_code_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_expires_at_idx": { + "name": "device_code_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forge_installation": { + "name": "forge_installation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "forge_installation_provider_external_unique": { + "name": "forge_installation_provider_external_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forge_installation_organization_idx": { + "name": "forge_installation_organization_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "forge_installation_organization_id_organization_id_fk": { + "name": "forge_installation_organization_id_organization_id_fk", + "tableFrom": "forge_installation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forge_installation_created_by_user_id_user_id_fk": { + "name": "forge_installation_created_by_user_id_user_id_fk", + "tableFrom": "forge_installation", + "tableTo": "user", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'app'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_role": { + "name": "organization_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preset_seat_limit": { + "name": "preset_seat_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pre_created_user_id": { + "name": "pre_created_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inviter_name": { + "name": "inviter_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_email": { + "name": "inviter_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invite_token_hash_unique": { + "name": "invite_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_email_idx": { + "name": "invite_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_status_idx": { + "name": "invite_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_organization_id_idx": { + "name": "invite_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_pre_created_user_id_idx": { + "name": "invite_pre_created_user_id_idx", + "columns": [ + { + "expression": "pre_created_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_created_by_user_id_idx": { + "name": "invite_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_expires_at_idx": { + "name": "invite_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite_use": { + "name": "invite_use", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invite_id": { + "name": "invite_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "used_by_user_id": { + "name": "used_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitee_email": { + "name": "invitee_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invite_use_invite_id_idx": { + "name": "invite_use_invite_id_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invite_use_invite_id_invite_id_fk": { + "name": "invite_use_invite_id_invite_id_fk", + "tableFrom": "invite_use", + "tableTo": "invite", + "columnsFrom": ["invite_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_organization_user_unique": { + "name": "member_organization_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seat_limit": { + "name": "seat_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_repository": { + "name": "organization_repository", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'observe'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_repository_coordinates_unique": { + "name": "organization_repository_coordinates_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organization_repository_installation_idx": { + "name": "organization_repository_installation_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_repository_organization_id_organization_id_fk": { + "name": "organization_repository_organization_id_organization_id_fk", + "tableFrom": "organization_repository", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_repository_installation_id_forge_installation_id_fk": { + "name": "organization_repository_installation_id_forge_installation_id_fk", + "tableFrom": "organization_repository", + "tableTo": "forge_installation", + "columnsFrom": ["installation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_setting": { + "name": "organization_setting", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_setting_organization_id_organization_id_fk": { + "name": "organization_setting_organization_id_organization_id_fk", + "tableFrom": "organization_setting", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "passkey_user_id_idx": { + "name": "passkey_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repository": { + "name": "repository", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_path": { + "name": "checkout_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'observe'" + }, + "poll_enabled": { + "name": "poll_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repository_checkout_path_unique": { + "name": "repository_checkout_path_unique", + "columns": [ + { + "expression": "checkout_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repository_provider_owner_name_unique": { + "name": "repository_provider_owner_name_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repository_poll_enabled_idx": { + "name": "repository_poll_enabled_idx", + "columns": [ + { + "expression": "poll_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_connection_binding": { + "name": "scim_connection_binding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_binding_connection_id_idx": { + "name": "scim_connection_binding_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_binding_connection_key_unique": { + "name": "scim_connection_binding_connection_key_unique", + "nullsNotDistinct": false, + "columns": ["connection_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group": { + "name": "scim_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_connection_id_idx": { + "name": "scim_group_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_provisioning_domain_id_idx": { + "name": "scim_group_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_display_name_key_unique": { + "name": "scim_group_display_name_key_unique", + "nullsNotDistinct": false, + "columns": ["display_name_key"] + }, + "scim_group_external_id_key_unique": { + "name": "scim_group_external_id_key_unique", + "nullsNotDistinct": false, + "columns": ["external_id_key"] + }, + "scim_group_order_key_unique": { + "name": "scim_group_order_key_unique", + "nullsNotDistinct": false, + "columns": ["order_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_member": { + "name": "scim_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_member_connection_id_idx": { + "name": "scim_group_member_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_group_id_idx": { + "name": "scim_group_member_group_id_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_scim_user_id_idx": { + "name": "scim_group_member_scim_user_id_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_member_group_id_scim_group_id_fk": { + "name": "scim_group_member_group_id_scim_group_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_member_scim_user_id_scim_user_id_fk": { + "name": "scim_group_member_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_member_membership_key_unique": { + "name": "scim_group_member_membership_key_unique", + "nullsNotDistinct": false, + "columns": ["membership_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstone": { + "name": "scim_identity_tombstone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstone_connection_id_idx": { + "name": "scim_identity_tombstone_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstone_provisioning_domain_id_idx": { + "name": "scim_identity_tombstone_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstone_user_id_idx": { + "name": "scim_identity_tombstone_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstone_user_id_user_id_fk": { + "name": "scim_identity_tombstone_user_id_user_id_fk", + "tableFrom": "scim_identity_tombstone", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstone_external_id_key_unique": { + "name": "scim_identity_tombstone_external_id_key_unique", + "nullsNotDistinct": false, + "columns": ["external_id_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_managed_connection": { + "name": "scim_managed_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "creation_request_id": { + "name": "creation_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decommission_started_at": { + "name": "decommission_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_started_by": { + "name": "decommission_started_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommissioned_by": { + "name": "decommissioned_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_managed_connection_provisioning_domain_id_idx": { + "name": "scim_managed_connection_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_managed_connection_creation_request_id_unique": { + "name": "scim_managed_connection_creation_request_id_unique", + "nullsNotDistinct": false, + "columns": ["creation_request_id"] + }, + "scim_managed_connection_connection_id_unique": { + "name": "scim_managed_connection_connection_id_unique", + "nullsNotDistinct": false, + "columns": ["connection_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_managed_connection_event": { + "name": "scim_managed_connection_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_record_id": { + "name": "connection_record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_managed_connection_event_connection_record_id_idx": { + "name": "scim_managed_connection_event_connection_record_id_idx", + "columns": [ + { + "expression": "connection_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_managed_connection_event_connection_record_id_scim_managed_connection_id_fk": { + "name": "scim_managed_connection_event_connection_record_id_scim_managed_connection_id_fk", + "tableFrom": "scim_managed_connection_event", + "tableTo": "scim_managed_connection", + "columnsFrom": ["connection_record_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_managed_connection_event_event_key_unique": { + "name": "scim_managed_connection_event_event_key_unique", + "nullsNotDistinct": false, + "columns": ["event_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_managed_credential": { + "name": "scim_managed_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_record_id": { + "name": "connection_record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_digest": { + "name": "token_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash_version": { + "name": "hash_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_slot_key": { + "name": "active_slot_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_scopes": { + "name": "serialized_scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_managed_credential_connection_record_id_idx": { + "name": "scim_managed_credential_connection_record_id_idx", + "columns": [ + { + "expression": "connection_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_managed_credential_connection_record_id_scim_managed_connection_id_fk": { + "name": "scim_managed_credential_connection_record_id_scim_managed_connection_id_fk", + "tableFrom": "scim_managed_credential", + "tableTo": "scim_managed_connection", + "columnsFrom": ["connection_record_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_managed_credential_credential_id_unique": { + "name": "scim_managed_credential_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + }, + "scim_managed_credential_active_slot_key_unique": { + "name": "scim_managed_credential_active_slot_key_unique", + "nullsNotDistinct": false, + "columns": ["active_slot_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grant": { + "name": "scim_projection_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grant_connection_id_idx": { + "name": "scim_projection_grant_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_provisioning_domain_id_idx": { + "name": "scim_projection_grant_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_scim_user_id_idx": { + "name": "scim_projection_grant_scim_user_id_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_user_id_idx": { + "name": "scim_projection_grant_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grant_scim_user_id_scim_user_id_fk": { + "name": "scim_projection_grant_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grant_user_id_user_id_fk": { + "name": "scim_projection_grant_user_id_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grant_grant_key_unique": { + "name": "scim_projection_grant_grant_key_unique", + "nullsNotDistinct": false, + "columns": ["grant_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subject": { + "name": "scim_subject", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subject_profile_source_id_idx": { + "name": "scim_subject_profile_source_id_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subject_user_id_user_id_fk": { + "name": "scim_subject_user_id_user_id_fk", + "tableFrom": "scim_subject", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subject_user_id_unique": { + "name": "scim_subject_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user": { + "name": "scim_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_user_connection_id_idx": { + "name": "scim_user_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_provisioning_domain_id_idx": { + "name": "scim_user_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_user_id_idx": { + "name": "scim_user_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_user_id_user_id_fk": { + "name": "scim_user_user_id_user_id_fk", + "tableFrom": "scim_user", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_user_connection_user_key_unique": { + "name": "scim_user_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": ["connection_user_key"] + }, + "scim_user_user_name_key_unique": { + "name": "scim_user_user_name_key_unique", + "nullsNotDistinct": false, + "columns": ["user_name_key"] + }, + "scim_user_external_id_key_unique": { + "name": "scim_user_external_id_key_unique", + "nullsNotDistinct": false, + "columns": ["external_id_key"] + }, + "scim_user_order_key_unique": { + "name": "scim_user_order_key_unique", + "nullsNotDistinct": false, + "columns": ["order_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_impersonated_by_user_id_fk": { + "name": "session_impersonated_by_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["impersonated_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_verification_count": { + "name": "failed_verification_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "two_factor_secret_idx": { + "name": "two_factor_secret_idx", + "columns": [ + { + "expression": "secret", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "two_factor_user_id_idx": { + "name": "two_factor_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_method": { + "name": "last_login_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index 49be302..aa702b6 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1792341556244, "tag": "0006_normal_felicia_hardy", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1794171121046, + "tag": "0007_silent_mordo", + "breakpoints": true } ] } diff --git a/packages/database/src/client.test.ts b/packages/database/src/client.test.ts index 7d31191..9ff6dea 100644 --- a/packages/database/src/client.test.ts +++ b/packages/database/src/client.test.ts @@ -94,11 +94,13 @@ describe('schema', () => { 'account', 'apikey', 'deviceCode', + 'forgeInstallation', 'invitation', 'invite', 'inviteUse', 'member', 'organization', + 'organizationRepository', 'passkey', 'repository', 'scimConnectionBinding', diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 666af6d..68d2809 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -19,6 +19,21 @@ export { type RepositoryRecord, type WatchedRepositoryRecord, } from './repositories.js'; +export { + attachInstallation, + InstallationAlreadyAttachedError, + InstallationNotFoundError, + listInstallations, + listOrganizationRepositories, + removeOrganizationRepository, + selectRepositories, + setInstallationSuspended, + type InstallationInput, + type InstallationRecord, + type OrganizationRepositoryMode, + type OrganizationRepositoryRecord, + type RepositorySelection, +} from './installations.js'; export { organizationMemberRole, readDeploymentSettings, @@ -32,11 +47,13 @@ export { apiKey, deploymentSetting, deviceCode, + forgeInstallation, invitation, invite, inviteUse, member, organization, + organizationRepository, organizationSetting, passkey, repository, diff --git a/packages/database/src/installations.test.ts b/packages/database/src/installations.test.ts new file mode 100644 index 0000000..788354b --- /dev/null +++ b/packages/database/src/installations.test.ts @@ -0,0 +1,184 @@ +import { fileURLToPath } from 'node:url'; + +import { drizzle } from 'drizzle-orm/pglite'; +import { migrate } from 'drizzle-orm/pglite/migrator'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + attachInstallation, + InstallationAlreadyAttachedError, + InstallationNotFoundError, + listInstallations, + listOrganizationRepositories, + removeOrganizationRepository, + selectRepositories, + setInstallationSuspended, +} from './installations.js'; +import { organization, schema, user } from './schema/index.js'; + +const MIGRATIONS = fileURLToPath(new URL('../drizzle', import.meta.url)); +const UNSUPPORTED_MODE = /mode/; + +/** The same in-memory Postgres, migrated with the checked-in migrations, as `settings.test.ts`. */ +const database = drizzle({ schema }); + +const acme = { + organizationId: 'org-a', + externalId: '1001', + accountLogin: 'acme', + accountType: 'Organization', + createdByUserId: 'u-owner', +}; + +beforeAll(async () => { + await migrate(database, { migrationsFolder: MIGRATIONS }); + await database.insert(user).values({ id: 'u-owner', name: 'Owner', email: 'owner@example.test' }); + await database.insert(organization).values([ + { id: 'org-a', name: 'A', slug: 'a' }, + { id: 'org-b', name: 'B', slug: 'b' }, + ]); +}, 60_000); + +afterAll(async () => { + await database.$client.close(); +}); + +describe('attachInstallation', () => { + it('attaches an installation to an organization', async () => { + const installation = await attachInstallation(database, acme); + + expect(installation).toMatchObject({ + organizationId: 'org-a', + provider: 'github', + externalId: '1001', + accountLogin: 'acme', + suspended: false, + }); + await expect(listInstallations(database, 'org-a')).resolves.toHaveLength(1); + }); + + it('is idempotent for the organization that already holds it', async () => { + const first = await attachInstallation(database, acme); + const again = await attachInstallation(database, { ...acme, accountLogin: 'acme-renamed' }); + + expect(again.id).toBe(first.id); + expect(again.accountLogin).toBe('acme-renamed'); + await expect(listInstallations(database, 'org-a')).resolves.toHaveLength(1); + }); + + it('refuses an installation another organization already holds', async () => { + await expect( + attachInstallation(database, { ...acme, organizationId: 'org-b' }), + ).rejects.toBeInstanceOf(InstallationAlreadyAttachedError); + await expect(listInstallations(database, 'org-b')).resolves.toEqual([]); + }); + + it('keeps the same external id apart across providers', async () => { + const other = await attachInstallation(database, { + ...acme, + organizationId: 'org-b', + provider: 'gitlab', + }); + + expect(other.provider).toBe('gitlab'); + }); +}); + +describe('setInstallationSuspended', () => { + it('records and clears a suspension', async () => { + await setInstallationSuspended(database, 'github', '1001', true); + await expect(listInstallations(database, 'org-a')).resolves.toMatchObject([ + { suspended: true }, + ]); + + await setInstallationSuspended(database, 'github', '1001', false); + await expect(listInstallations(database, 'org-a')).resolves.toMatchObject([ + { suspended: false }, + ]); + }); + + it('ignores an installation it does not know', async () => { + await expect(setInstallationSuspended(database, 'github', 'nope', true)).resolves.toBe(false); + }); +}); + +describe('selectRepositories', () => { + const [first, second] = [ + { externalId: '501', owner: 'acme', name: 'api' }, + { externalId: '502', owner: 'acme', name: 'web' }, + ]; + + it('selects the chosen repositories in observe mode by default', async () => { + const [installation] = await listInstallations(database, 'org-a'); + if (!installation) throw new Error('fixture installation missing'); + + const saved = await selectRepositories(database, { + organizationId: 'org-a', + installationId: installation.id, + repositories: [first, second], + }); + + expect(saved.map((r) => `${r.owner}/${r.name}:${r.mode}`)).toEqual([ + 'acme/api:observe', + 'acme/web:observe', + ]); + }); + + it('updates the mode of a repository that is already selected, without duplicating it', async () => { + const [installation] = await listInstallations(database, 'org-a'); + if (!installation) throw new Error('fixture installation missing'); + + await selectRepositories(database, { + organizationId: 'org-a', + installationId: installation.id, + repositories: [{ ...first, mode: 'suggest' }], + }); + + const repositories = await listOrganizationRepositories(database, 'org-a'); + expect(repositories).toHaveLength(2); + expect(repositories.find((r) => r.name === 'api')?.mode).toBe('suggest'); + }); + + it('refuses a writable mode', async () => { + const [installation] = await listInstallations(database, 'org-a'); + if (!installation) throw new Error('fixture installation missing'); + + await expect( + selectRepositories(database, { + organizationId: 'org-a', + installationId: installation.id, + // oxlint-disable-next-line no-unsafe-type-assertion -- deliberately invalid input under test + repositories: [{ ...first, mode: 'fix' as 'observe' }], + }), + ).rejects.toThrow(UNSUPPORTED_MODE); + }); + + it("refuses an installation that belongs to another organization's account", async () => { + const [installation] = await listInstallations(database, 'org-a'); + if (!installation) throw new Error('fixture installation missing'); + + await expect( + selectRepositories(database, { + organizationId: 'org-b', + installationId: installation.id, + repositories: [first], + }), + ).rejects.toBeInstanceOf(InstallationNotFoundError); + await expect(listOrganizationRepositories(database, 'org-b')).resolves.toEqual([]); + }); +}); + +describe('removeOrganizationRepository', () => { + it('removes a repository only from its own organization', async () => { + const [repository] = await listOrganizationRepositories(database, 'org-a'); + if (!repository) throw new Error('fixture repository missing'); + + await expect(removeOrganizationRepository(database, 'org-b', repository.id)).resolves.toBe( + false, + ); + await expect(removeOrganizationRepository(database, 'org-a', repository.id)).resolves.toBe( + true, + ); + await expect(listOrganizationRepositories(database, 'org-a')).resolves.toHaveLength(1); + }); +}); diff --git a/packages/database/src/installations.ts b/packages/database/src/installations.ts new file mode 100644 index 0000000..2726849 --- /dev/null +++ b/packages/database/src/installations.ts @@ -0,0 +1,305 @@ +import { and, asc, eq } from 'drizzle-orm'; +import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core'; + +import type { Schema } from './schema/index.js'; +import { forgeInstallation, organizationRepository } from './schema/installation.js'; + +/** Any Drizzle Postgres client opened on this package's schema, as `settings.ts` accepts one. */ +type InstallationDatabase = PgDatabase; + +/** The modes an organization's repository may be selected with: the two that cannot write. */ +export type OrganizationRepositoryMode = 'observe' | 'suggest'; + +const MODES = new Set(['observe', 'suggest']); + +function isMode(value: string): value is OrganizationRepositoryMode { + return MODES.has(value); +} + +/** An installation attached to an organization, as every consumer outside this package sees one. */ +export interface InstallationRecord { + id: string; + organizationId: string; + provider: string; + externalId: string; + accountLogin: string; + accountType: string; + suspended: boolean; +} + +/** A repository an organization selected, with the installation that reaches it. */ +export interface OrganizationRepositoryRecord { + id: string; + organizationId: string; + installationId: string; + provider: string; + externalId: string; + owner: string; + name: string; + mode: OrganizationRepositoryMode; +} + +/** What a caller supplies to attach an installation. The store mints the identity. */ +export interface InstallationInput { + organizationId: string; + provider?: string; + externalId: string; + accountLogin: string; + accountType: string; + createdByUserId?: string | null; +} + +/** One repository to select. */ +export interface RepositorySelection { + externalId: string; + owner: string; + name: string; + mode?: OrganizationRepositoryMode; +} + +/** The installation is already attached to a different organization. */ +export class InstallationAlreadyAttachedError extends Error { + constructor() { + super('This installation is already attached to another organization'); + this.name = 'InstallationAlreadyAttachedError'; + } +} + +/** The installation does not exist, or does not belong to the organization asking. */ +export class InstallationNotFoundError extends Error { + constructor() { + super('This installation is not attached to the organization'); + this.name = 'InstallationNotFoundError'; + } +} + +/** + * Attach an installation to an organization. + * + * Idempotent for the organization that already holds it, so a reloaded callback page changes + * nothing. Refused when another organization holds it: the unique index is what makes that true + * under concurrency, and the conflict it raises is translated here rather than left as a driver + * error nobody can tell apart from a failure. + */ +export async function attachInstallation( + database: InstallationDatabase, + input: InstallationInput, +): Promise { + const provider = input.provider ?? 'github'; + const [existing] = await database + .select() + .from(forgeInstallation) + .where( + and( + eq(forgeInstallation.provider, provider), + eq(forgeInstallation.externalId, input.externalId), + ), + ) + .limit(1); + + if (existing) { + if (existing.organizationId !== input.organizationId) { + throw new InstallationAlreadyAttachedError(); + } + const [updated] = await database + .update(forgeInstallation) + .set({ + accountLogin: input.accountLogin, + accountType: input.accountType, + updatedAt: new Date(), + }) + .where(eq(forgeInstallation.id, existing.id)) + .returning(); + if (!updated) throw new Error('The installation could not be updated'); + return toInstallation(updated); + } + + try { + const [created] = await database + .insert(forgeInstallation) + .values({ + id: globalThis.crypto.randomUUID(), + organizationId: input.organizationId, + provider, + externalId: input.externalId, + accountLogin: input.accountLogin, + accountType: input.accountType, + createdByUserId: input.createdByUserId ?? null, + }) + .returning(); + if (!created) throw new Error('The installation could not be attached'); + return toInstallation(created); + } catch (error) { + // A concurrent request attached it between the read above and this insert. + if (isUniqueViolation(error)) throw new InstallationAlreadyAttachedError(); + throw error; + } +} + +/** The installations an organization has attached, oldest first. */ +export async function listInstallations( + database: InstallationDatabase, + organizationId: string, +): Promise { + const rows = await database + .select() + .from(forgeInstallation) + .where(eq(forgeInstallation.organizationId, organizationId)) + .orderBy(asc(forgeInstallation.createdAt)); + return rows.map(toInstallation); +} + +/** + * Record that the provider suspended or resumed an installation. + * + * Keyed by the provider's coordinates, not the row id, because the provider's webhook names an + * installation by its own id. Returns whether a row was found: a webhook for an installation nobody + * attached is expected and is not an error. + */ +export async function setInstallationSuspended( + database: InstallationDatabase, + provider: string, + externalId: string, + suspended: boolean, +): Promise { + const rows = await database + .update(forgeInstallation) + .set({ suspendedAt: suspended ? new Date() : null, updatedAt: new Date() }) + .where( + and(eq(forgeInstallation.provider, provider), eq(forgeInstallation.externalId, externalId)), + ) + .returning({ id: forgeInstallation.id }); + return rows.length > 0; +} + +/** + * Select repositories for an organization through one of its installations. + * + * The installation is looked up under the organization first, so an id belonging to someone else's + * installation is indistinguishable from one that does not exist. A repository already selected has + * its mode updated instead of being duplicated. + */ +export async function selectRepositories( + database: InstallationDatabase, + input: { + organizationId: string; + installationId: string; + repositories: readonly RepositorySelection[]; + }, +): Promise { + for (const repository of input.repositories) { + const mode = repository.mode ?? 'observe'; + if (!isMode(mode)) + throw new Error('Unsupported repository mode: only observe and suggest are accepted'); + } + + const [installation] = await database + .select() + .from(forgeInstallation) + .where( + and( + eq(forgeInstallation.id, input.installationId), + eq(forgeInstallation.organizationId, input.organizationId), + ), + ) + .limit(1); + if (!installation) throw new InstallationNotFoundError(); + + const saved: OrganizationRepositoryRecord[] = []; + for (const repository of input.repositories) { + const mode = repository.mode ?? 'observe'; + const [row] = await database + .insert(organizationRepository) + .values({ + id: globalThis.crypto.randomUUID(), + organizationId: input.organizationId, + installationId: installation.id, + provider: installation.provider, + externalId: repository.externalId, + owner: repository.owner, + name: repository.name, + mode, + }) + .onConflictDoUpdate({ + target: [ + organizationRepository.organizationId, + organizationRepository.provider, + organizationRepository.owner, + organizationRepository.name, + ], + set: { mode, externalId: repository.externalId, updatedAt: new Date() }, + }) + .returning(); + if (!row) throw new Error('The repository could not be selected'); + saved.push(toRepository(row)); + } + return saved; +} + +/** The repositories an organization selected, oldest first. */ +export async function listOrganizationRepositories( + database: InstallationDatabase, + organizationId: string, +): Promise { + const rows = await database + .select() + .from(organizationRepository) + .where(eq(organizationRepository.organizationId, organizationId)) + .orderBy(asc(organizationRepository.createdAt)); + return rows.map(toRepository); +} + +/** Remove a selected repository, only from the organization that holds it. */ +export async function removeOrganizationRepository( + database: InstallationDatabase, + organizationId: string, + id: string, +): Promise { + const rows = await database + .delete(organizationRepository) + .where( + and( + eq(organizationRepository.id, id), + eq(organizationRepository.organizationId, organizationId), + ), + ) + .returning({ id: organizationRepository.id }); + return rows.length > 0; +} + +function toInstallation(row: typeof forgeInstallation.$inferSelect): InstallationRecord { + return { + id: row.id, + organizationId: row.organizationId, + provider: row.provider, + externalId: row.externalId, + accountLogin: row.accountLogin, + accountType: row.accountType, + suspended: row.suspendedAt !== null, + }; +} + +function toRepository( + row: typeof organizationRepository.$inferSelect, +): OrganizationRepositoryRecord { + if (!isMode(row.mode)) throw new Error(`Stored repository has an unsupported mode: ${row.mode}`); + return { + id: row.id, + organizationId: row.organizationId, + installationId: row.installationId, + provider: row.provider, + externalId: row.externalId, + owner: row.owner, + name: row.name, + mode: row.mode, + }; +} + +/** Postgres reports a unique violation as SQLSTATE 23505, on the error or on its `cause`. */ +function isUniqueViolation(error: unknown): boolean { + for (let current: unknown = error; typeof current === 'object' && current !== null;) { + if ('code' in current && current.code === '23505') return true; + current = 'cause' in current ? current.cause : undefined; + } + return false; +} diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index 8d123f0..3705777 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -2,6 +2,7 @@ import { apiKey } from './api-key.js'; import { account, session, user, verification } from './auth.js'; import { deviceCode } from './device.js'; import { invite, inviteUse } from './enrollment.js'; +import { forgeInstallation, organizationRepository } from './installation.js'; import { invitation, member, organization } from './organization.js'; import { passkey } from './passkey.js'; import { repository } from './repository.js'; @@ -25,6 +26,7 @@ export { account, session, user, verification } from './auth.js'; export { timestampColumns } from './columns.js'; export { deviceCode } from './device.js'; export { invite, inviteUse } from './enrollment.js'; +export { forgeInstallation, organizationRepository } from './installation.js'; export { invitation, member, organization } from './organization.js'; export { passkey } from './passkey.js'; export { repository } from './repository.js'; @@ -82,6 +84,8 @@ export const schema = { scimGroup, scimGroupMember, repository, + forgeInstallation, + organizationRepository, }; /** The set of tables the database client is opened with. */ diff --git a/packages/database/src/schema/installation.ts b/packages/database/src/schema/installation.ts new file mode 100644 index 0000000..29306af --- /dev/null +++ b/packages/database/src/schema/installation.ts @@ -0,0 +1,88 @@ +import { index, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; + +import { user } from './auth.js'; +import { timestampColumns } from './columns.js'; +import { organization } from './organization.js'; + +/** + * A source-control installation attached to one organization: the GitHub App installed on a user or + * an organization account, as the managed cloud sees it. + * + * This is the cloud's counterpart to `repository`, which describes checkouts on a host. A cloud + * deployment has no checkout, so what a customer connects is an installation, and what it may act + * on is whatever the installation grants. + * + * No credential lives here. An installation token is minted per request from the app's own key, + * which is a deployment secret; the row only records which installation belongs to which + * organization. + */ +export const forgeInstallation = pgTable( + 'forge_installation', + { + id: text('id').primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + /** Which provider `externalId` is an installation of. Only `github` exists today. */ + provider: text('provider').notNull().default('github'), + /** The provider's installation id, kept as text so a provider with another id shape fits. */ + externalId: text('external_id').notNull(), + /** The user or organization account the app is installed on, as the provider names it. */ + accountLogin: text('account_login').notNull(), + accountType: text('account_type').notNull(), + /** + * Who attached it. Kept when the account is deleted so the trail survives, but no longer + * names anyone. + */ + createdByUserId: text('created_by_user_id').references(() => user.id, { + onDelete: 'set null', + }), + /** Set while the provider reports the installation suspended; null while it is active. */ + suspendedAt: timestamp('suspended_at', { withTimezone: true }), + ...timestampColumns, + }, + (table) => [ + // An installation belongs to exactly one organization. The database enforces it, because two + // requests that each checked first would otherwise both attach the same installation. + uniqueIndex('forge_installation_provider_external_unique').on(table.provider, table.externalId), + index('forge_installation_organization_idx').on(table.organizationId), + ], +); + +/** + * A repository an organization chose to have Code Zero watch, reached through one installation. + * + * `owner` and `name` are the provider coordinates. `externalId` is the provider's own repository + * id, so a rename on the provider does not leave the row pointing at nothing. + */ +export const organizationRepository = pgTable( + 'organization_repository', + { + id: text('id').primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + installationId: text('installation_id') + .notNull() + .references(() => forgeInstallation.id, { onDelete: 'cascade' }), + provider: text('provider').notNull().default('github'), + externalId: text('external_id').notNull(), + owner: text('owner').notNull(), + name: text('name').notNull(), + /** + * The execution mode un-requested work runs under. Only the two non-writable modes are + * accepted, checked where rows are written, as `repository.mode` is. + */ + mode: text('mode').notNull().default('observe'), + ...timestampColumns, + }, + (table) => [ + uniqueIndex('organization_repository_coordinates_unique').on( + table.organizationId, + table.provider, + table.owner, + table.name, + ), + index('organization_repository_installation_idx').on(table.installationId), + ], +); From e2183d766eb0da9f59d570ace7d6f64c79f7feea Mon Sep 17 00:00:00 2001 From: RedStar Date: Sun, 8 Nov 2026 22:12:57 +0100 Subject: [PATCH 2/4] feat(api): connect an organization's code through a GitHub App installation Add the installation procedures: start the install flow, complete it, list installations, list what an installation can see, and select, list or remove an organization's repositories. An installation id arrives in a query string, so completing an install requires the provider to confirm that the person controls it, on top of a state token signed for them and for one organization. Selecting a repository names it by the provider's id; owner and name come from what the installation can see, never from the caller. The provider and the store are contracts the composition root implements, as RepositoryAdmin is. --- ...26-11-08-onboarding-installation-design.md | 20 +- packages/api/src/index.ts | 14 + packages/api/src/installations.test.ts | 63 +++ packages/api/src/installations.ts | 220 ++++++++ packages/api/src/orpc/installations.test.ts | 483 ++++++++++++++++++ packages/api/src/orpc/installations.ts | 296 +++++++++++ packages/api/src/orpc/router.ts | 102 ++++ 7 files changed, 1189 insertions(+), 9 deletions(-) create mode 100644 packages/api/src/installations.test.ts create mode 100644 packages/api/src/installations.ts create mode 100644 packages/api/src/orpc/installations.test.ts create mode 100644 packages/api/src/orpc/installations.ts diff --git a/docs/plans/2026-11-08-onboarding-installation-design.md b/docs/plans/2026-11-08-onboarding-installation-design.md index ba5ac25..6a93293 100644 --- a/docs/plans/2026-11-08-onboarding-installation-design.md +++ b/docs/plans/2026-11-08-onboarding-installation-design.md @@ -88,13 +88,13 @@ New procedures in `packages/api`, behind the existing `authenticated` middleware only. Each checks organization membership itself (the active organization on a session is a claim, not a grant), the way `settings.organization.*` does. -| procedure | role needed | does | -| ------------------------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `installations.start` | owner or admin | returns the GitHub App install URL carrying a signed `state` bound to the user and organization | -| `installations.complete` | owner or admin | verifies `state`, verifies control of `installation_id`, stores the row | -| `installations.list` | member | the organization's installations | -| `installations.repositories` | owner or admin | the repositories the installation can see, from the provider, never cached in the database | -| `repositories.organization.save` / `.remove` / `.list` | owner or admin to write, member to read | the selected repositories | +| procedure | role needed | does | +| ------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `installations.start` | owner or admin | returns the GitHub App install URL carrying a signed `state` bound to the user and organization | +| `installations.complete` | owner or admin | verifies `state`, verifies control of `installation_id`, stores the row | +| `installations.list` | member | the organization's installations | +| `installations.repositories` | owner or admin | the repositories the installation can see, from the provider, never cached in the database | +| `organizationRepositories.select` / `.remove` / `.list` | owner or admin to write, member to read | the selected repositories; `select` names repositories by provider id and takes owner and name from what the installation can see | `packages/api` defines the contracts structurally, as it does for `RepositoryAdmin`: @@ -119,8 +119,10 @@ check would let anyone attach another customer's installation by guessing an id. `installations.complete` therefore needs the user authorization `code` GitHub sends back when the app is configured to request user authorization during installation. It exchanges the code for a user token with the app's client id and secret, lists `GET /user/installations`, and accepts the id only if -it appears there. `state` is signed with a Worker secret, expires in ten minutes, and is single use, -which closes the cross-site request forgery path. +it appears there. `state` is signed with a Worker secret, bound to the person and the organization it +was issued for, and expires in ten minutes, which closes the cross-site request forgery path. It is +not single use: replaying one inside its window attaches the same installation again, which is a +no-op, so a nonce store would add state without closing anything. Open item: from `app.code-zero.dev` the Worker currently gets HTTP 525 when it calls `https://github.com/login/oauth/access_token`, the same endpoint this exchange needs. The same Worker diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 39374bf..f61e544 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -84,6 +84,20 @@ export type { RepositoryMode, RepositoryRecord, } from './repositories.js'; +export { + createInstallationState, + InstallationVerificationError, + type InstallationProvider, + type InstallationService, + type InstallationState, + type InstallationStateClaims, + type InstallationStore, + type InstallationView, + type InstalledAccount, + type RepositoryChoice, + type SelectedRepositoryView, + type VisibleRepository, +} from './installations.js'; export { deploymentServices } from './settings.js'; export type { DashboardSettingsAuthority, diff --git a/packages/api/src/installations.test.ts b/packages/api/src/installations.test.ts new file mode 100644 index 0000000..f195fbf --- /dev/null +++ b/packages/api/src/installations.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { createInstallationState } from './installations.js'; + +const SECRET = 'a-secret-that-is-long-enough-for-hmac-signing'; +const TEN_MINUTES = 10 * 60 * 1000; +const AT_LEAST = /at least/; + +describe('createInstallationState', () => { + it('returns what it was given, for the same secret', async () => { + const state = createInstallationState(SECRET); + + const token = await state.sign({ userId: 'usr_1', organizationId: 'org-a' }); + + await expect(state.verify(token)).resolves.toEqual({ + userId: 'usr_1', + organizationId: 'org-a', + }); + }); + + it('refuses a token signed with another secret', async () => { + const token = await createInstallationState(SECRET).sign({ + userId: 'usr_1', + organizationId: 'org-a', + }); + + await expect( + createInstallationState('another-secret-entirely-different').verify(token), + ).resolves.toBeNull(); + }); + + it('refuses a token whose payload was changed', async () => { + const state = createInstallationState(SECRET); + const token = await state.sign({ userId: 'usr_1', organizationId: 'org-a' }); + const [, signature] = token.split('.'); + const forged = `${btoa(JSON.stringify({ userId: 'usr_2', organizationId: 'org-a', exp: Date.now() + TEN_MINUTES }))}.${signature}`; + + await expect(state.verify(forged)).resolves.toBeNull(); + }); + + it('refuses a token after ten minutes', async () => { + let now = 1_000_000; + const state = createInstallationState(SECRET, () => now); + const token = await state.sign({ userId: 'usr_1', organizationId: 'org-a' }); + + now += TEN_MINUTES - 1; + await expect(state.verify(token)).resolves.not.toBeNull(); + + now += 2; + await expect(state.verify(token)).resolves.toBeNull(); + }); + + it.each(['', 'nodot', 'a.b.c', '.', 'not base64.not base64'])( + 'refuses a malformed token %j without throwing', + async (token) => { + await expect(createInstallationState(SECRET).verify(token)).resolves.toBeNull(); + }, + ); + + it('refuses a secret too short to sign with', () => { + expect(() => createInstallationState('short')).toThrow(AT_LEAST); + }); +}); diff --git a/packages/api/src/installations.ts b/packages/api/src/installations.ts new file mode 100644 index 0000000..caa7632 --- /dev/null +++ b/packages/api/src/installations.ts @@ -0,0 +1,220 @@ +import type { OrganizationMembership } from './settings.js'; + +/** + * What the control plane needs from the source-control provider when an organization connects its + * code: where to send a person to install the app, proof that they control what they claim to have + * installed, and what an installation can see. + * + * Declared here, not imported from `@code-zero/forge`, for the reason `RepositoryAdmin` is: this + * package composes adapters into the API and does not talk to a provider itself. `apps/web` backs + * it with the provider; a test backs it with a fake. + */ +export interface InstallationProvider { + /** The page the person is sent to in order to install the app, carrying the signed state. */ + installUrl(state: string): string; + /** + * Prove that the person who completed the install flow controls `installationId`, and return the + * account it is installed on. + * + * `userCode` is the authorization code the provider sent back with them. The installation id alone + * proves nothing: it arrives in a query string, so anyone can write any id into it. + * + * Throws {@link InstallationVerificationError} when the claim cannot be confirmed, and any other + * error when the provider could not be asked. + */ + verify(input: { installationId: string; userCode: string }): Promise; + /** Every repository the installation can currently see, with the provider's own coordinates. */ + repositories(installationId: string): Promise; +} + +/** The account an installation is installed on. */ +export interface InstalledAccount { + login: string; + type: string; +} + +/** A repository an installation can see. */ +export interface VisibleRepository { + externalId: string; + owner: string; + name: string; + private: boolean; + archived: boolean; +} + +/** The provider says this person does not control this installation. */ +export class InstallationVerificationError extends Error { + constructor() { + super('The installation could not be verified for this account'); + this.name = 'InstallationVerificationError'; + } +} + +/** An installation attached to an organization, as the API returns it. */ +export interface InstallationView { + id: string; + provider: string; + externalId: string; + accountLogin: string; + accountType: string; + suspended: boolean; +} + +/** A repository an organization selected. */ +export interface SelectedRepositoryView { + id: string; + installationId: string; + provider: string; + owner: string; + name: string; + mode: 'observe' | 'suggest'; +} + +/** One repository to select, named by the provider's id and nothing else. */ +export interface RepositoryChoice { + externalId: string; + mode?: 'observe' | 'suggest' | undefined; +} + +/** + * The installations and selected repositories a deployment's store keeps. + * + * Membership is read here, as `OrganizationSettingsStore` does, because a session remembers the + * organization it last acted in and not whether its account still belongs there. + */ +export interface InstallationStore { + membership(organizationId: string, userId: string): Promise; + /** `taken` when another organization already holds the installation. */ + attach(input: { + organizationId: string; + externalId: string; + accountLogin: string; + accountType: string; + createdByUserId: string; + }): Promise<{ ok: true; installation: InstallationView } | { ok: false; reason: 'taken' }>; + list(organizationId: string): Promise; + /** The installation, only if it belongs to this organization. */ + find(organizationId: string, installationId: string): Promise; + select(input: { + organizationId: string; + installationId: string; + repositories: readonly { + externalId: string; + owner: string; + name: string; + mode?: 'observe' | 'suggest' | undefined; + }[]; + }): Promise; + listRepositories(organizationId: string): Promise; + removeRepository(organizationId: string, id: string): Promise; +} + +/** Everything the installation procedures need, handed over by the composition root. */ +export interface InstallationService { + readonly provider: InstallationProvider; + readonly store: InstallationStore; + readonly state: InstallationState; +} + +/** The claims a state token carries. */ +export interface InstallationStateClaims { + userId: string; + organizationId: string; +} + +/** + * Signs and checks the `state` that travels to the provider and back during installation. + * + * It binds the round trip to one person and one organization, so a callback cannot be replayed for + * someone else or attached to another organization, and it expires, so a link found in a history + * file stops working. Replaying a valid one inside its window only attaches the same installation + * again, which is a no-op. + */ +export interface InstallationState { + sign(claims: InstallationStateClaims): Promise; + /** The claims, or null for anything that is not a valid, unexpired token from this secret. */ + verify(token: string): Promise; +} + +const STATE_LIFETIME_MS = 10 * 60 * 1000; +const MINIMUM_SECRET_LENGTH = 32; + +export function createInstallationState( + secret: string, + now: () => number = Date.now, +): InstallationState { + if (secret.length < MINIMUM_SECRET_LENGTH) { + throw new Error(`The state secret must be at least ${MINIMUM_SECRET_LENGTH} characters`); + } + const encoder = new TextEncoder(); + const key = globalThis.crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'], + ); + + return { + async sign(claims) { + const payload = toBase64Url( + encoder.encode(JSON.stringify({ ...claims, exp: now() + STATE_LIFETIME_MS })), + ); + const signature = await globalThis.crypto.subtle.sign( + 'HMAC', + await key, + encoder.encode(payload), + ); + return `${payload}.${toBase64Url(new Uint8Array(signature))}`; + }, + async verify(token) { + const parts = token.split('.'); + const [payload, signature] = parts; + if (parts.length !== 2 || !payload || !signature) return null; + const signatureBytes = fromBase64Url(signature); + if (!signatureBytes) return null; + const valid = await globalThis.crypto.subtle.verify( + 'HMAC', + await key, + signatureBytes, + encoder.encode(payload), + ); + if (!valid) return null; + return claimsFrom(payload, now()); + }, + }; +} + +function claimsFrom(payload: string, at: number): InstallationStateClaims | null { + const bytes = fromBase64Url(payload); + if (!bytes) return null; + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null) return null; + const userId = 'userId' in parsed ? parsed.userId : undefined; + const organizationId = 'organizationId' in parsed ? parsed.organizationId : undefined; + const exp = 'exp' in parsed ? parsed.exp : undefined; + if (typeof userId !== 'string' || typeof organizationId !== 'string') return null; + if (typeof exp !== 'number' || exp <= at) return null; + return { userId, organizationId }; +} + +function toBase64Url(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) binary += String.fromCodePoint(byte); + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); +} + +function fromBase64Url(value: string): Uint8Array | null { + try { + const padded = value.replaceAll('-', '+').replaceAll('_', '/'); + const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, '=')); + return Uint8Array.from(binary, (char) => char.codePointAt(0) ?? 0); + } catch { + return null; + } +} diff --git a/packages/api/src/orpc/installations.test.ts b/packages/api/src/orpc/installations.test.ts new file mode 100644 index 0000000..4ef8f1d --- /dev/null +++ b/packages/api/src/orpc/installations.test.ts @@ -0,0 +1,483 @@ +import { createRouterClient } from '@orpc/server'; +import { createRequestLogger } from 'evlog'; +import { describe, expect, it } from 'vitest'; + +import type { Principal } from '../access.js'; +import { MemoryTaskStore } from '../control-plane.js'; +import { + createInstallationState, + InstallationVerificationError, + type InstallationProvider, + type InstallationService, + type InstallationStore, + type InstallationView, + type SelectedRepositoryView, + type VisibleRepository, +} from '../installations.js'; +import type { OrganizationMembership } from '../settings.js'; +import { requestLoggerStorage } from './logging.js'; +import { rpcRouter } from './router.js'; + +const OWNER: OrganizationMembership = { role: 'owner', managesPolicy: true }; +const MEMBER: OrganizationMembership = { role: 'member', managesPolicy: false }; +const FORBIDDEN = /FORBIDDEN|not a member|requires|session|not issued|could not be verified/i; +const NOT_FOUND = /NOT_FOUND|not attached/i; +const NOT_A_MEMBER = /not a member/; +const NO_SERVICE = /NOT_IMPLEMENTED|no installation/i; +const CONFLICT = /CONFLICT|already/i; +const NOT_VISIBLE = /not visible|cannot see/i; +const INVALID_INPUT = /validation|invalid/i; +const SECRET = 'a-secret-that-is-long-enough-for-hmac-signing'; + +const VISIBLE: VisibleRepository[] = [ + { externalId: '501', owner: 'acme', name: 'api', private: true, archived: false }, + { externalId: '502', owner: 'acme', name: 'web', private: false, archived: false }, +]; + +function instrumented(run: () => Promise): Promise { + if (!requestLoggerStorage) return run(); + const logger = createRequestLogger(); + return requestLoggerStorage.run(logger, async () => { + try { + return await run(); + } finally { + logger.emit(); + } + }); +} + +function session(userId = 'usr_1'): Principal { + return { + name: `${userId}@example.test`, + kind: 'session', + modes: ['observe', 'suggest'], + admin: false, + account: { userId, activeOrganizationId: null }, + }; +} + +const operatorToken: Principal = { + name: 'service-ops', + kind: 'token', + modes: ['observe', 'suggest'], + admin: false, +}; + +/** A provider that knows which `userCode` proves which installation, like the real one would. */ +function fakeProvider(proofs = new Map([['good-code', '1001']])): InstallationProvider { + return { + installUrl: (state) => `https://github.example/apps/code-zero/installations/new?state=${state}`, + verify: ({ installationId, userCode }) => { + if (proofs.get(userCode) !== installationId) { + return Promise.reject(new InstallationVerificationError()); + } + return Promise.resolve({ login: 'acme', type: 'Organization' }); + }, + repositories: () => Promise.resolve(VISIBLE), + }; +} + +/** Memberships and rows in memory, with the one-organization-per-installation rule enforced. */ +function memoryStore(members: Map>) { + const installations: (InstallationView & { organizationId: string })[] = []; + const repositories: (SelectedRepositoryView & { organizationId: string })[] = []; + const store: InstallationStore = { + membership: (organizationId, userId) => + Promise.resolve(members.get(organizationId)?.get(userId) ?? null), + attach: (input) => { + const held = installations.find((row) => row.externalId === input.externalId); + if (held && held.organizationId !== input.organizationId) { + return Promise.resolve({ ok: false as const, reason: 'taken' as const }); + } + const row = held ?? { + id: `inst-${installations.length + 1}`, + organizationId: input.organizationId, + provider: 'github', + externalId: input.externalId, + accountLogin: input.accountLogin, + accountType: input.accountType, + suspended: false, + }; + if (!held) installations.push(row); + return Promise.resolve({ ok: true as const, installation: row }); + }, + list: (organizationId) => + Promise.resolve(installations.filter((row) => row.organizationId === organizationId)), + find: (organizationId, installationId) => + Promise.resolve( + installations.find( + (row) => row.organizationId === organizationId && row.id === installationId, + ) ?? null, + ), + select: ({ organizationId, installationId, repositories: chosen }) => { + const saved = chosen.map((repository) => { + const row = { + id: `repo-${repositories.length + 1}`, + organizationId, + installationId, + provider: 'github', + owner: repository.owner, + name: repository.name, + mode: repository.mode ?? ('observe' as const), + }; + repositories.push(row); + return row; + }); + return Promise.resolve(saved); + }, + listRepositories: (organizationId) => + Promise.resolve(repositories.filter((row) => row.organizationId === organizationId)), + removeRepository: (organizationId, id) => { + const index = repositories.findIndex( + (row) => row.id === id && row.organizationId === organizationId, + ); + if (index === -1) return Promise.resolve(false); + repositories.splice(index, 1); + return Promise.resolve(true); + }, + }; + return { store, installations, repositories }; +} + +function routerClient(principal: Principal, service: InstallationService | null) { + return createRouterClient(rpcRouter, { + context: { + store: new MemoryTaskStore(), + principal, + mayTargetRepository: () => true, + ...(service ? { installations: service } : {}), + }, + }); +} + +function setup(options: { proofs?: Map } = {}) { + const members = new Map([ + [ + 'org-a', + new Map([ + ['usr_1', OWNER], + ['usr_member', MEMBER], + ]), + ], + ['org-b', new Map([['usr_other', OWNER]])], + ]); + const memory = memoryStore(members); + const state = createInstallationState(SECRET); + const installations: InstallationService = { + provider: fakeProvider(options.proofs), + store: memory.store, + state, + }; + const client = (principal: Principal, service: InstallationService | null = installations) => + routerClient(principal, service); + return { client, memory, state, installations }; +} + +async function attached(setupResult: ReturnType, code = 'good-code') { + const token = await setupResult.state.sign({ userId: 'usr_1', organizationId: 'org-a' }); + return instrumented(() => + setupResult.client(session()).installations.complete({ + state: token, + installationId: '1001', + code, + }), + ); +} + +describe('installations.start', () => { + it('sends an organization owner to the install page with a state bound to them', async () => { + const { client, state } = setup(); + + const { url } = await instrumented(() => + client(session()).installations.start({ organizationId: 'org-a' }), + ); + + const token = new URL(url).searchParams.get('state') ?? ''; + await expect(state.verify(token)).resolves.toEqual({ + userId: 'usr_1', + organizationId: 'org-a', + }); + }); + + it('refuses a member who may not manage the organization', async () => { + await expect( + instrumented(() => + setup().client(session('usr_member')).installations.start({ organizationId: 'org-a' }), + ), + ).rejects.toThrow(FORBIDDEN); + }); + + it('refuses someone who is not in the organization, as it refuses a missing one', async () => { + const { client } = setup(); + + const outsider = instrumented(() => + client(session('usr_other')).installations.start({ organizationId: 'org-a' }), + ); + const missing = instrumented(() => + client(session()).installations.start({ organizationId: 'org-missing' }), + ); + + await expect(outsider).rejects.toThrow(NOT_A_MEMBER); + await expect(missing).rejects.toThrow(NOT_A_MEMBER); + }); + + it('refuses an operator token', async () => { + await expect( + instrumented(() => + setup().client(operatorToken).installations.start({ organizationId: 'org-a' }), + ), + ).rejects.toThrow(FORBIDDEN); + }); + + it('says so when the deployment has no installation service', async () => { + await expect( + instrumented(() => + setup().client(session(), null).installations.start({ organizationId: 'org-a' }), + ), + ).rejects.toThrow(NO_SERVICE); + }); +}); + +describe('installations.complete', () => { + it('attaches the installation the person proved they control', async () => { + const result = setup(); + + await expect(attached(result)).resolves.toMatchObject({ + externalId: '1001', + accountLogin: 'acme', + }); + expect(result.memory.installations).toHaveLength(1); + }); + + it('attaches nothing when the provider cannot confirm the claim', async () => { + const result = setup(); + + await expect(attached(result, 'a-code-for-another-installation')).rejects.toThrow(FORBIDDEN); + expect(result.memory.installations).toEqual([]); + }); + + it('refuses a state issued to someone else', async () => { + const result = setup(); + const token = await result.state.sign({ userId: 'usr_other', organizationId: 'org-b' }); + + await expect( + instrumented(() => + result.client(session()).installations.complete({ + state: token, + installationId: '1001', + code: 'good-code', + }), + ), + ).rejects.toThrow(FORBIDDEN); + expect(result.memory.installations).toEqual([]); + }); + + it('refuses a state that was not signed by this deployment', async () => { + const result = setup(); + const forged = await createInstallationState('another-secret-entirely-different').sign({ + userId: 'usr_1', + organizationId: 'org-a', + }); + + await expect( + instrumented(() => + result.client(session()).installations.complete({ + state: forged, + installationId: '1001', + code: 'good-code', + }), + ), + ).rejects.toThrow(FORBIDDEN); + }); + + it('refuses an installation another organization already holds', async () => { + const result = setup(); + await attached(result); + const token = await result.state.sign({ userId: 'usr_other', organizationId: 'org-b' }); + + await expect( + instrumented(() => + result.client(session('usr_other')).installations.complete({ + state: token, + installationId: '1001', + code: 'good-code', + }), + ), + ).rejects.toThrow(CONFLICT); + }); + + it('is a no-op when the same organization completes it twice', async () => { + const result = setup(); + + await attached(result); + await attached(result); + + expect(result.memory.installations).toHaveLength(1); + }); + + it('refuses a person who stopped managing the organization after starting', async () => { + const result = setup(); + const token = await result.state.sign({ userId: 'usr_member', organizationId: 'org-a' }); + + await expect( + instrumented(() => + result.client(session('usr_member')).installations.complete({ + state: token, + installationId: '1001', + code: 'good-code', + }), + ), + ).rejects.toThrow(FORBIDDEN); + }); +}); + +describe('installations.list', () => { + it('shows a member the organization installations, and no one else', async () => { + const result = setup(); + await attached(result); + + await expect( + instrumented(() => + result.client(session('usr_member')).installations.list({ organizationId: 'org-a' }), + ), + ).resolves.toHaveLength(1); + await expect( + instrumented(() => + result.client(session('usr_other')).installations.list({ organizationId: 'org-a' }), + ), + ).rejects.toThrow(NOT_A_MEMBER); + }); +}); + +describe('installations.repositories', () => { + it('lists what the installation can see', async () => { + const result = setup(); + const installation = await attached(result); + + await expect( + instrumented(() => + result.client(session()).installations.repositories({ + organizationId: 'org-a', + installationId: installation.id, + }), + ), + ).resolves.toEqual(VISIBLE); + }); + + it("does not reach another organization's installation", async () => { + const result = setup(); + const installation = await attached(result); + + await expect( + instrumented(() => + result.client(session('usr_other')).installations.repositories({ + organizationId: 'org-b', + installationId: installation.id, + }), + ), + ).rejects.toThrow(NOT_FOUND); + }); +}); + +describe('organizationRepositories', () => { + it('selects repositories by id and takes their coordinates from the provider', async () => { + const result = setup(); + const installation = await attached(result); + + const saved = await instrumented(() => + result.client(session()).organizationRepositories.select({ + organizationId: 'org-a', + installationId: installation.id, + repositories: [{ externalId: '501' }, { externalId: '502', mode: 'suggest' }], + }), + ); + + expect(saved.map((row) => `${row.owner}/${row.name}:${row.mode}`)).toEqual([ + 'acme/api:observe', + 'acme/web:suggest', + ]); + }); + + it('refuses a repository the installation cannot see', async () => { + const result = setup(); + const installation = await attached(result); + + await expect( + instrumented(() => + result.client(session()).organizationRepositories.select({ + organizationId: 'org-a', + installationId: installation.id, + repositories: [{ externalId: '999' }], + }), + ), + ).rejects.toThrow(NOT_VISIBLE); + expect(result.memory.repositories).toEqual([]); + }); + + it('refuses a writable mode at the wire', async () => { + const result = setup(); + const installation = await attached(result); + + await expect( + instrumented(() => + result.client(session()).organizationRepositories.select({ + organizationId: 'org-a', + installationId: installation.id, + // oxlint-disable-next-line no-unsafe-type-assertion -- deliberately invalid input under test + repositories: [{ externalId: '501', mode: 'fix' as 'observe' }], + }), + ), + ).rejects.toThrow(INVALID_INPUT); + }); + + it('lets a member read the selection but not change it', async () => { + const result = setup(); + const installation = await attached(result); + await instrumented(() => + result.client(session()).organizationRepositories.select({ + organizationId: 'org-a', + installationId: installation.id, + repositories: [{ externalId: '501' }], + }), + ); + + const member = result.client(session('usr_member')); + await expect( + instrumented(() => member.organizationRepositories.list({ organizationId: 'org-a' })), + ).resolves.toHaveLength(1); + await expect( + instrumented(() => + member.organizationRepositories.remove({ organizationId: 'org-a', id: 'repo-1' }), + ), + ).rejects.toThrow(FORBIDDEN); + }); + + it('removes a repository from its own organization only', async () => { + const result = setup(); + const installation = await attached(result); + await instrumented(() => + result.client(session()).organizationRepositories.select({ + organizationId: 'org-a', + installationId: installation.id, + repositories: [{ externalId: '501' }], + }), + ); + + await expect( + instrumented(() => + result.client(session('usr_other')).organizationRepositories.remove({ + organizationId: 'org-b', + id: 'repo-1', + }), + ), + ).resolves.toEqual({ removed: false }); + await expect( + instrumented(() => + result.client(session()).organizationRepositories.remove({ + organizationId: 'org-a', + id: 'repo-1', + }), + ), + ).resolves.toEqual({ removed: true }); + }); +}); diff --git a/packages/api/src/orpc/installations.ts b/packages/api/src/orpc/installations.ts new file mode 100644 index 0000000..d0ff91a --- /dev/null +++ b/packages/api/src/orpc/installations.ts @@ -0,0 +1,296 @@ +import { ORPCError } from '@orpc/server'; +import { z } from 'zod'; + +import type { Principal } from '../access.js'; +import { + InstallationVerificationError, + type InstallationService, + type InstallationView, + type SelectedRepositoryView, + type VisibleRepository, +} from '../installations.js'; +import { principalActor } from './actor.js'; +import { useLogger } from './logging.js'; + +/** What every handler below reads from the procedure context. */ +export interface InstallationContext { + principal: Principal; + installations?: InstallationService | undefined; +} + +/** The organization is named, then membership is checked. */ +export const installationOrganizationInput = z.object({ organizationId: z.string().min(1) }); + +export const installationScopeInput = installationOrganizationInput.extend({ + installationId: z.string().min(1), +}); + +/** + * `installations.complete`'s input: exactly what the provider's callback carries. + * + * The organization is not here. It comes from the signed `state`, so a caller cannot name one that + * the round trip was not started for. + */ +export const completeInstallationInput = z.object({ + state: z.string().min(1), + installationId: z.string().min(1), + code: z.string().min(1), +}); + +/** One repository to select: the provider's id, and how un-requested work may run on it. */ +export const selectRepositoriesInput = installationScopeInput.extend({ + repositories: z + .array( + z.object({ + externalId: z.string().min(1), + mode: z.enum(['observe', 'suggest']).optional(), + }), + ) + .min(1) + .max(100), +}); + +export const removeRepositoryInput = installationOrganizationInput.extend({ + id: z.string().min(1), +}); + +const SESSION_ONLY = 'Connecting code is done from a signed-in dashboard session'; +const NOT_A_MEMBER = 'You are not a member of this organization'; +const NOT_A_MANAGER = 'Connecting code requires the owner or admin role'; +const NO_SERVICE = 'This deployment has no installation service'; +const STATE_REFUSED = 'The installation link was not issued to you, or it has expired'; +const NOT_VERIFIED = 'The installation could not be verified for this account'; +const ALREADY_ATTACHED = 'This installation is already attached to another organization'; +const NOT_ATTACHED = 'This installation is not attached to the organization'; +const NOT_VISIBLE = 'A repository is not visible to this installation'; +const PROVIDER_FAILED = 'The source-control provider could not be reached'; + +/** `installations.start`: where to send the person to install the app. */ +export async function startInstallation( + context: InstallationContext, + input: z.infer, +): Promise<{ url: string }> { + const { service, userId } = await manager(context, 'installation.start', input.organizationId); + const state = await service.state.sign({ userId, organizationId: input.organizationId }); + return { url: service.provider.installUrl(state) }; +} + +/** + * `installations.complete`: attach what the person proved they control. + * + * Three things have to hold, and each is checked on the server whatever the page did: the `state` + * was signed here for this person, they still manage the organization it names, and the provider + * confirms they control the installation. The installation id alone is never enough. + */ +export async function completeInstallation( + context: InstallationContext, + input: z.infer, +): Promise { + const service = requireService(context.installations); + const { principal } = context; + const target = { type: 'installation', id: input.installationId }; + const userId = sessionUser(principal, 'installation.complete', target); + + const claims = await service.state.verify(input.state); + if (claims?.userId !== userId) refuse(principal, 'installation.complete', target, STATE_REFUSED); + await requireManager(service, principal, 'installation.complete', target, { + organizationId: claims.organizationId, + userId, + }); + + let account; + try { + account = await service.provider.verify({ + installationId: input.installationId, + userCode: input.code, + }); + } catch (error) { + if (error instanceof InstallationVerificationError) { + refuse(principal, 'installation.complete', target, NOT_VERIFIED); + } + throw new ORPCError('BAD_GATEWAY', { message: PROVIDER_FAILED }); + } + + const attached = await service.store.attach({ + organizationId: claims.organizationId, + externalId: input.installationId, + accountLogin: account.login, + accountType: account.type, + createdByUserId: userId, + }); + if (!attached.ok) { + useLogger().audit.deny(ALREADY_ATTACHED, { + actor: principalActor(principal), + action: 'installation.complete', + target, + }); + throw new ORPCError('CONFLICT', { message: ALREADY_ATTACHED }); + } + useLogger().audit({ + actor: principalActor(principal), + action: 'installation.attached', + outcome: 'success', + target: { type: 'organization', id: claims.organizationId }, + }); + return attached.installation; +} + +/** `installations.list`: the organization's installations, for any member. */ +export async function listInstallations( + context: InstallationContext, + input: z.infer, +): Promise { + const { service } = await member(context, 'installation.list', input.organizationId); + return service.store.list(input.organizationId); +} + +/** `installations.repositories`: what one of the organization's installations can see right now. */ +export async function installationRepositories( + context: InstallationContext, + input: z.infer, +): Promise { + const { service } = await manager(context, 'installation.repositories', input.organizationId); + const installation = await service.store.find(input.organizationId, input.installationId); + if (!installation) throw new ORPCError('NOT_FOUND', { message: NOT_ATTACHED }); + return visibleRepositories(service, installation); +} + +/** + * `organizationRepositories.select`: choose repositories to watch. + * + * The caller names provider ids only. Owner and name come from what the installation can see, so a + * client cannot select a repository the installation does not reach by spelling its coordinates. + */ +export async function selectOrganizationRepositories( + context: InstallationContext, + input: z.infer, +): Promise { + const { service, userId } = await manager(context, 'repository.select', input.organizationId); + const installation = await service.store.find(input.organizationId, input.installationId); + if (!installation) throw new ORPCError('NOT_FOUND', { message: NOT_ATTACHED }); + + const visible = new Map( + (await visibleRepositories(service, installation)).map((repository) => [ + repository.externalId, + repository, + ]), + ); + const chosen = input.repositories.map((choice) => { + const repository = visible.get(choice.externalId); + if (!repository) throw new ORPCError('BAD_REQUEST', { message: NOT_VISIBLE }); + return { + externalId: repository.externalId, + owner: repository.owner, + name: repository.name, + mode: choice.mode, + }; + }); + + const saved = await service.store.select({ + organizationId: input.organizationId, + installationId: installation.id, + repositories: chosen, + }); + useLogger().audit({ + actor: principalActor(context.principal), + action: 'repository.selected', + outcome: 'success', + target: { type: 'organization', id: input.organizationId, count: saved.length, by: userId }, + }); + return saved; +} + +/** `organizationRepositories.list`: the selection, for any member. */ +export async function listOrganizationRepositories( + context: InstallationContext, + input: z.infer, +): Promise { + const { service } = await member(context, 'repository.list', input.organizationId); + return service.store.listRepositories(input.organizationId); +} + +/** `organizationRepositories.remove`: stop watching a repository. */ +export async function removeOrganizationRepository( + context: InstallationContext, + input: z.infer, +): Promise<{ removed: boolean }> { + const { service } = await manager(context, 'repository.remove', input.organizationId); + const removed = await service.store.removeRepository(input.organizationId, input.id); + useLogger().audit({ + actor: principalActor(context.principal), + action: 'repository.deselected', + outcome: removed ? 'success' : 'failure', + target: { type: 'repository', id: input.id }, + }); + return { removed }; +} + +async function visibleRepositories( + service: InstallationService, + installation: InstallationView, +): Promise { + try { + return await service.provider.repositories(installation.externalId); + } catch { + throw new ORPCError('BAD_GATEWAY', { message: PROVIDER_FAILED }); + } +} + +function requireService(service: InstallationService | undefined): InstallationService { + if (!service) throw new ORPCError('NOT_IMPLEMENTED', { message: NO_SERVICE }); + return service; +} + +/** The signed-in account behind the call. Tokens and API keys act for no one in particular. */ +function sessionUser( + principal: Principal, + action: string, + target: { type: string; id: string }, +): string { + if (principal.kind !== 'session' || !principal.account) { + refuse(principal, action, target, SESSION_ONLY); + } + return principal.account.userId; +} + +async function member(context: InstallationContext, action: string, organizationId: string) { + const service = requireService(context.installations); + const target = { type: 'organization', id: organizationId }; + const userId = sessionUser(context.principal, action, target); + const membership = await service.store.membership(organizationId, userId); + // One answer for "no such organization" and "not yours", so a refusal does not reveal which + // organizations exist. + if (!membership) refuse(context.principal, action, target, NOT_A_MEMBER); + return { service, userId, membership }; +} + +async function manager(context: InstallationContext, action: string, organizationId: string) { + const result = await member(context, action, organizationId); + if (!result.membership.managesPolicy) { + refuse(context.principal, action, { type: 'organization', id: organizationId }, NOT_A_MANAGER); + } + return result; +} + +async function requireManager( + service: InstallationService, + principal: Principal, + action: string, + target: { type: string; id: string }, + who: { organizationId: string; userId: string }, +): Promise { + const membership = await service.store.membership(who.organizationId, who.userId); + if (!membership) refuse(principal, action, target, NOT_A_MEMBER); + if (!membership.managesPolicy) refuse(principal, action, target, NOT_A_MANAGER); +} + +/** A refusal is recorded on the trail as well as thrown, as every other procedure's are. */ +function refuse( + principal: Principal, + action: string, + target: { type: string; id: string }, + message: string, +): never { + useLogger().audit.deny(message, { actor: principalActor(principal), action, target }); + throw new ORPCError('FORBIDDEN', { message }); +} diff --git a/packages/api/src/orpc/router.ts b/packages/api/src/orpc/router.ts index 321353a..142b4e3 100644 --- a/packages/api/src/orpc/router.ts +++ b/packages/api/src/orpc/router.ts @@ -14,6 +14,7 @@ import { z } from 'zod'; import type { AuditLogStore } from '../audit.js'; import type { TaskStore } from '../control-plane.js'; import { dashboardOverview } from '../dashboard.js'; +import type { InstallationService } from '../installations.js'; import { approvalInput, createTask, @@ -27,6 +28,20 @@ import type { RepositoryAdmin } from '../repositories.js'; import type { DeploymentSettingsAuthority } from '../settings.js'; import { principalActor } from './actor.js'; import { authMiddleware, type BetterAuthContext } from './auth.js'; +import { + completeInstallation, + completeInstallationInput, + installationOrganizationInput, + installationRepositories, + installationScopeInput, + listInstallations, + listOrganizationRepositories, + removeOrganizationRepository, + removeRepositoryInput, + selectOrganizationRepositories, + selectRepositoriesInput, + startInstallation, +} from './installations.js'; import { useLogger } from './logging.js'; import { deploymentSettingsInput, @@ -77,6 +92,12 @@ export interface RpcContext extends BetterAuthContext { * under the repository's own policy, as before settings existed. */ settings?: DeploymentSettingsAuthority; + /** + * Connecting an organization's code: the provider's install flow, what an installation can see, + * and where the selection is kept. Optional like the capabilities above; `installations.*` and + * `organizationRepositories.*` report that the deployment has none rather than pretending. + */ + installations?: InstallationService; } /** @@ -413,6 +434,87 @@ export const rpcRouter = { .handler(({ input, context }) => updateOrganizationSettings(context, input)), }, }, + installations: { + start: authenticated + .meta( + openapi({ + method: 'POST', + path: '/organizations/{organizationId}/installations/start', + tags: ['Installations'], + summary: 'Start installing the GitHub App for an organization', + }), + ) + .input(installationOrganizationInput) + .handler(({ input, context }) => startInstallation(context, input)), + complete: authenticated + .meta( + openapi({ + method: 'POST', + path: '/installations/complete', + tags: ['Installations'], + summary: 'Attach the installation the person proved they control', + }), + ) + .input(completeInstallationInput) + .handler(({ input, context }) => completeInstallation(context, input)), + list: authenticated + .meta( + openapi({ + method: 'GET', + path: '/organizations/{organizationId}/installations', + tags: ['Installations'], + summary: "List an organization's installations", + }), + ) + .input(installationOrganizationInput) + .handler(({ input, context }) => listInstallations(context, input)), + repositories: authenticated + .meta( + openapi({ + method: 'GET', + path: '/organizations/{organizationId}/installations/{installationId}/repositories', + tags: ['Installations'], + summary: 'List the repositories an installation can see', + }), + ) + .input(installationScopeInput) + .handler(({ input, context }) => installationRepositories(context, input)), + }, + organizationRepositories: { + list: authenticated + .meta( + openapi({ + method: 'GET', + path: '/organizations/{organizationId}/repositories', + tags: ['Installations'], + summary: "List an organization's selected repositories", + }), + ) + .input(installationOrganizationInput) + .handler(({ input, context }) => listOrganizationRepositories(context, input)), + select: authenticated + .meta( + openapi({ + method: 'POST', + path: '/organizations/{organizationId}/repositories', + tags: ['Installations'], + summary: 'Select repositories to watch, by provider id', + }), + ) + .input(selectRepositoriesInput) + .handler(({ input, context }) => selectOrganizationRepositories(context, input)), + remove: authenticated + .meta( + openapi({ + method: 'DELETE', + path: '/organizations/{organizationId}/repositories/{id}', + tags: ['Installations'], + summary: 'Stop watching a selected repository', + }), + ) + .input(removeRepositoryInput) + .handler(({ input, context }) => removeOrganizationRepository(context, input)), + }, approvals: { decide: authenticated .meta( From f3feed5073c870ea20f0e7163c5940ee1a112d68 Mon Sep 17 00:00:00 2001 From: RedStar Date: Sun, 8 Nov 2026 22:38:57 +0100 Subject: [PATCH 3/4] docs(plans): record the 525 cause and its fix in the onboarding design The Worker's calls to hosts outside Cloudflare failed because the code-zero.dev zone required a post-quantum hybrid key exchange towards origins. The zone setting is fixed; note it where the installation check depends on it. --- .../plans/2026-11-08-onboarding-installation-design.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-11-08-onboarding-installation-design.md b/docs/plans/2026-11-08-onboarding-installation-design.md index 6a93293..d5d9797 100644 --- a/docs/plans/2026-11-08-onboarding-installation-design.md +++ b/docs/plans/2026-11-08-onboarding-installation-design.md @@ -124,10 +124,12 @@ was issued for, and expires in ten minutes, which closes the cross-site request not single use: replaying one inside its window attaches the same installation again, which is a no-op, so a nonce store would add state without closing anything. -Open item: from `app.code-zero.dev` the Worker currently gets HTTP 525 when it calls -`https://github.com/login/oauth/access_token`, the same endpoint this exchange needs. The same Worker -reached through `workers.dev` works. Until that is resolved, the install step cannot complete on the -custom domain. The code can be built and tested against a fake provider meanwhile. +Resolved: from `app.code-zero.dev` the Worker used to get HTTP 525 on every request to a host that is +not behind Cloudflare, `https://github.com/login/oauth/access_token` included. The cause was the +`code-zero.dev` zone's origin TLS compliance setting (`origin_tls_compliance_modes`), which required a +post-quantum hybrid key exchange (`pqh`). Hosts behind Cloudflare negotiate it; GitHub, Wikipedia and +AWS do not. The zone now keeps `fips` only. Anything that makes the Worker call a non-Cloudflare host +(GitHub, a model provider, a mail API) depends on that setting staying without `pqh`. ## GitHub App **(owner: to be created)** From e37ba2c7b1fd67733535423fd588b0c5873005c3 Mon Sep 17 00:00:00 2001 From: RedStar Date: Mon, 9 Nov 2026 13:24:46 +0100 Subject: [PATCH 4/4] fix(database): identify a selected repository by the provider's id Selecting a repository matched on owner and name, so a rename added a second entry and a reinstalled app left the row naming the old installation. The row is now keyed by (organization, provider, external id) and refreshes coordinates, mode and installation. Two simultaneous first attaches by the same organization no longer report a conflict: the loser re-reads the winning row and treats it as the idempotent case. A stored mode that is not observe or suggest reads as observe instead of failing the listing. --- ...26-11-08-onboarding-installation-design.md | 4 +- ...ilent_mordo.sql => 0007_lucky_tempest.sql} | 2 +- .../database/drizzle/meta/0007_snapshot.json | 14 +-- packages/database/drizzle/meta/_journal.json | 4 +- packages/database/src/installations.test.ts | 69 +++++++++++++- packages/database/src/installations.ts | 93 ++++++++++++------- packages/database/src/schema/installation.ts | 10 +- 7 files changed, 139 insertions(+), 57 deletions(-) rename packages/database/drizzle/{0007_silent_mordo.sql => 0007_lucky_tempest.sql} (92%) diff --git a/docs/plans/2026-11-08-onboarding-installation-design.md b/docs/plans/2026-11-08-onboarding-installation-design.md index d5d9797..324cdaf 100644 --- a/docs/plans/2026-11-08-onboarding-installation-design.md +++ b/docs/plans/2026-11-08-onboarding-installation-design.md @@ -76,11 +76,11 @@ Unique on `(provider, external_id)`: one installation, one organization. | `organization_id` | references `organization.id`, cascade | | `installation_id` | references `forge_installation.id`, cascade | | `provider`, `owner`, `name` | coordinates on the provider | -| `external_id` | the provider's repository id, so a rename does not lose the row | +| `external_id` | the provider's repository id; it identifies the row, so a rename updates it | | `mode` | `observe` or `suggest`, checked where rows are written, as `repository.mode` is | | timestamps | `timestampColumns` | -Unique on `(organization_id, provider, owner, name)`. +Unique on `(organization_id, provider, external_id)`: the provider's repository id identifies the row, so a rename or a reinstall updates it instead of adding a second entry. ## API diff --git a/packages/database/drizzle/0007_silent_mordo.sql b/packages/database/drizzle/0007_lucky_tempest.sql similarity index 92% rename from packages/database/drizzle/0007_silent_mordo.sql rename to packages/database/drizzle/0007_lucky_tempest.sql index ba323f5..b1da032 100644 --- a/packages/database/drizzle/0007_silent_mordo.sql +++ b/packages/database/drizzle/0007_lucky_tempest.sql @@ -30,5 +30,5 @@ ALTER TABLE "organization_repository" ADD CONSTRAINT "organization_repository_or ALTER TABLE "organization_repository" ADD CONSTRAINT "organization_repository_installation_id_forge_installation_id_fk" FOREIGN KEY ("installation_id") REFERENCES "public"."forge_installation"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint CREATE UNIQUE INDEX "forge_installation_provider_external_unique" ON "forge_installation" USING btree ("provider","external_id");--> statement-breakpoint CREATE INDEX "forge_installation_organization_idx" ON "forge_installation" USING btree ("organization_id");--> statement-breakpoint -CREATE UNIQUE INDEX "organization_repository_coordinates_unique" ON "organization_repository" USING btree ("organization_id","provider","owner","name");--> statement-breakpoint +CREATE UNIQUE INDEX "organization_repository_external_unique" ON "organization_repository" USING btree ("organization_id","provider","external_id");--> statement-breakpoint CREATE INDEX "organization_repository_installation_idx" ON "organization_repository" USING btree ("installation_id"); \ No newline at end of file diff --git a/packages/database/drizzle/meta/0007_snapshot.json b/packages/database/drizzle/meta/0007_snapshot.json index b7103ce..f9348bd 100644 --- a/packages/database/drizzle/meta/0007_snapshot.json +++ b/packages/database/drizzle/meta/0007_snapshot.json @@ -1,5 +1,5 @@ { - "id": "2f568e95-b94b-4601-87ea-ebacb1485e37", + "id": "5d352941-dd3b-469d-a8fe-2e54654e4078", "prevId": "4c305f87-c67c-412c-ae9e-acfdbc7b25c0", "version": "7", "dialect": "postgresql", @@ -1382,8 +1382,8 @@ } }, "indexes": { - "organization_repository_coordinates_unique": { - "name": "organization_repository_coordinates_unique", + "organization_repository_external_unique": { + "name": "organization_repository_external_unique", "columns": [ { "expression": "organization_id", @@ -1398,13 +1398,7 @@ "nulls": "last" }, { - "expression": "owner", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "name", + "expression": "external_id", "isExpression": false, "asc": true, "nulls": "last" diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index aa702b6..e006eb6 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -54,8 +54,8 @@ { "idx": 7, "version": "7", - "when": 1794171121046, - "tag": "0007_silent_mordo", + "when": 1794226924042, + "tag": "0007_lucky_tempest", "breakpoints": true } ] diff --git a/packages/database/src/installations.test.ts b/packages/database/src/installations.test.ts index 788354b..b5b84ba 100644 --- a/packages/database/src/installations.test.ts +++ b/packages/database/src/installations.test.ts @@ -1,5 +1,6 @@ import { fileURLToPath } from 'node:url'; +import { eq } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/pglite'; import { migrate } from 'drizzle-orm/pglite/migrator'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -14,7 +15,13 @@ import { selectRepositories, setInstallationSuspended, } from './installations.js'; -import { organization, schema, user } from './schema/index.js'; +import { + forgeInstallation, + organization, + organizationRepository, + schema, + user, +} from './schema/index.js'; const MIGRATIONS = fileURLToPath(new URL('../drizzle', import.meta.url)); const UNSUPPORTED_MODE = /mode/; @@ -73,6 +80,23 @@ describe('attachInstallation', () => { await expect(listInstallations(database, 'org-b')).resolves.toEqual([]); }); + it('treats two simultaneous attaches by the same organization as one', async () => { + const input = { ...acme, externalId: '2002' }; + + const [first, second] = await Promise.all([ + attachInstallation(database, input), + attachInstallation(database, input), + ]); + + expect(second.id).toBe(first.id); + await expect( + listInstallations(database, 'org-a').then((rows) => + rows.filter((r) => r.externalId === '2002'), + ), + ).resolves.toHaveLength(1); + await database.delete(forgeInstallation).where(eq(forgeInstallation.id, first.id)); + }); + it('keeps the same external id apart across providers', async () => { const other = await attachInstallation(database, { ...acme, @@ -139,6 +163,49 @@ describe('selectRepositories', () => { expect(repositories.find((r) => r.name === 'api')?.mode).toBe('suggest'); }); + it('follows a renamed repository instead of adding a second entry', async () => { + const [installation] = await listInstallations(database, 'org-a'); + if (!installation) throw new Error('fixture installation missing'); + const before = await listOrganizationRepositories(database, 'org-a'); + + const [renamed] = await selectRepositories(database, { + organizationId: 'org-a', + installationId: installation.id, + repositories: [{ externalId: '501', owner: 'acme', name: 'backend', mode: 'suggest' }], + }); + + const after = await listOrganizationRepositories(database, 'org-a'); + expect(after).toHaveLength(before.length); + expect(renamed).toMatchObject({ externalId: '501', name: 'backend', mode: 'suggest' }); + expect(after.filter((r) => r.externalId === '501')).toHaveLength(1); + }); + + it('records the installation the repository was last selected through', async () => { + const [installation] = await listInstallations(database, 'org-a'); + if (!installation) throw new Error('fixture installation missing'); + const reinstalled = await attachInstallation(database, { ...acme, externalId: '1002' }); + + const [saved] = await selectRepositories(database, { + organizationId: 'org-a', + installationId: reinstalled.id, + repositories: [{ externalId: '502', owner: 'acme', name: 'web' }], + }); + + expect(saved?.installationId).toBe(reinstalled.id); + expect(saved?.installationId).not.toBe(installation.id); + }); + + it('reads a stored mode it does not recognise as observe', async () => { + await database + .update(organizationRepository) + .set({ mode: 'fix' }) + .where(eq(organizationRepository.externalId, '502')); + + const repositories = await listOrganizationRepositories(database, 'org-a'); + + expect(repositories.find((r) => r.externalId === '502')?.mode).toBe('observe'); + }); + it('refuses a writable mode', async () => { const [installation] = await listInstallations(database, 'org-a'); if (!installation) throw new Error('fixture installation missing'); diff --git a/packages/database/src/installations.ts b/packages/database/src/installations.ts index 2726849..0630060 100644 --- a/packages/database/src/installations.ts +++ b/packages/database/src/installations.ts @@ -86,33 +86,22 @@ export async function attachInstallation( input: InstallationInput, ): Promise { const provider = input.provider ?? 'github'; - const [existing] = await database - .select() - .from(forgeInstallation) - .where( - and( - eq(forgeInstallation.provider, provider), - eq(forgeInstallation.externalId, input.externalId), - ), - ) - .limit(1); + const find = async () => { + const [row] = await database + .select() + .from(forgeInstallation) + .where( + and( + eq(forgeInstallation.provider, provider), + eq(forgeInstallation.externalId, input.externalId), + ), + ) + .limit(1); + return row; + }; - if (existing) { - if (existing.organizationId !== input.organizationId) { - throw new InstallationAlreadyAttachedError(); - } - const [updated] = await database - .update(forgeInstallation) - .set({ - accountLogin: input.accountLogin, - accountType: input.accountType, - updatedAt: new Date(), - }) - .where(eq(forgeInstallation.id, existing.id)) - .returning(); - if (!updated) throw new Error('The installation could not be updated'); - return toInstallation(updated); - } + const existing = await find(); + if (existing) return refreshInstallation(database, existing, input); try { const [created] = await database @@ -130,10 +119,36 @@ export async function attachInstallation( if (!created) throw new Error('The installation could not be attached'); return toInstallation(created); } catch (error) { - // A concurrent request attached it between the read above and this insert. - if (isUniqueViolation(error)) throw new InstallationAlreadyAttachedError(); - throw error; + if (!isUniqueViolation(error)) throw error; + // A concurrent request attached it between the read above and this insert. Whose it is + // decides the answer: the same organization attaching twice is the idempotent case, not a + // conflict. + const winner = await find(); + if (!winner) throw error; + return refreshInstallation(database, winner, input); + } +} + +/** Idempotent re-attach for the organization that holds the row; a refusal for anyone else. */ +async function refreshInstallation( + database: InstallationDatabase, + existing: typeof forgeInstallation.$inferSelect, + input: InstallationInput, +): Promise { + if (existing.organizationId !== input.organizationId) { + throw new InstallationAlreadyAttachedError(); } + const [updated] = await database + .update(forgeInstallation) + .set({ + accountLogin: input.accountLogin, + accountType: input.accountType, + updatedAt: new Date(), + }) + .where(eq(forgeInstallation.id, existing.id)) + .returning(); + if (!updated) throw new Error('The installation could not be updated'); + return toInstallation(updated); } /** The installations an organization has attached, oldest first. */ @@ -176,8 +191,9 @@ export async function setInstallationSuspended( * Select repositories for an organization through one of its installations. * * The installation is looked up under the organization first, so an id belonging to someone else's - * installation is indistinguishable from one that does not exist. A repository already selected has - * its mode updated instead of being duplicated. + * installation is indistinguishable from one that does not exist. A repository is identified by the + * provider's id, so selecting one again, after a rename or through a reinstalled app, updates its + * coordinates, mode and installation instead of adding a second entry. */ export async function selectRepositories( database: InstallationDatabase, @@ -224,10 +240,15 @@ export async function selectRepositories( target: [ organizationRepository.organizationId, organizationRepository.provider, - organizationRepository.owner, - organizationRepository.name, + organizationRepository.externalId, ], - set: { mode, externalId: repository.externalId, updatedAt: new Date() }, + set: { + owner: repository.owner, + name: repository.name, + mode, + installationId: installation.id, + updatedAt: new Date(), + }, }) .returning(); if (!row) throw new Error('The repository could not be selected'); @@ -282,7 +303,6 @@ function toInstallation(row: typeof forgeInstallation.$inferSelect): Installatio function toRepository( row: typeof organizationRepository.$inferSelect, ): OrganizationRepositoryRecord { - if (!isMode(row.mode)) throw new Error(`Stored repository has an unsupported mode: ${row.mode}`); return { id: row.id, organizationId: row.organizationId, @@ -291,7 +311,8 @@ function toRepository( externalId: row.externalId, owner: row.owner, name: row.name, - mode: row.mode, + // A hand-edited value reads as the mode that cannot write, as `repositories.ts` does. + mode: isMode(row.mode) ? row.mode : 'observe', }; } diff --git a/packages/database/src/schema/installation.ts b/packages/database/src/schema/installation.ts index 29306af..b8067a8 100644 --- a/packages/database/src/schema/installation.ts +++ b/packages/database/src/schema/installation.ts @@ -52,8 +52,9 @@ export const forgeInstallation = pgTable( /** * A repository an organization chose to have Code Zero watch, reached through one installation. * - * `owner` and `name` are the provider coordinates. `externalId` is the provider's own repository - * id, so a rename on the provider does not leave the row pointing at nothing. + * `owner` and `name` are the provider coordinates, refreshed whenever the repository is selected + * again. `externalId` is the provider's own repository id and is what identifies the row, so a + * rename on the provider updates it instead of adding a second entry for the same repository. */ export const organizationRepository = pgTable( 'organization_repository', @@ -77,11 +78,10 @@ export const organizationRepository = pgTable( ...timestampColumns, }, (table) => [ - uniqueIndex('organization_repository_coordinates_unique').on( + uniqueIndex('organization_repository_external_unique').on( table.organizationId, table.provider, - table.owner, - table.name, + table.externalId, ), index('organization_repository_installation_idx').on(table.installationId), ],