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..324cdaf --- /dev/null +++ b/docs/plans/2026-11-08-onboarding-installation-design.md @@ -0,0 +1,168 @@ +# 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; 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, 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 + +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 | +| `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`: + +```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, 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. + +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)** + +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/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( diff --git a/packages/database/drizzle/0007_lucky_tempest.sql b/packages/database/drizzle/0007_lucky_tempest.sql new file mode 100644 index 0000000..b1da032 --- /dev/null +++ b/packages/database/drizzle/0007_lucky_tempest.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_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 new file mode 100644 index 0000000..f9348bd --- /dev/null +++ b/packages/database/drizzle/meta/0007_snapshot.json @@ -0,0 +1,3633 @@ +{ + "id": "5d352941-dd3b-469d-a8fe-2e54654e4078", + "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_external_unique": { + "name": "organization_repository_external_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "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..e006eb6 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": 1794226924042, + "tag": "0007_lucky_tempest", + "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..b5b84ba --- /dev/null +++ b/packages/database/src/installations.test.ts @@ -0,0 +1,251 @@ +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'; + +import { + attachInstallation, + InstallationAlreadyAttachedError, + InstallationNotFoundError, + listInstallations, + listOrganizationRepositories, + removeOrganizationRepository, + selectRepositories, + setInstallationSuspended, +} from './installations.js'; +import { + forgeInstallation, + organization, + organizationRepository, + 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('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, + 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('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'); + + 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..0630060 --- /dev/null +++ b/packages/database/src/installations.ts @@ -0,0 +1,326 @@ +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 find = async () => { + const [row] = await database + .select() + .from(forgeInstallation) + .where( + and( + eq(forgeInstallation.provider, provider), + eq(forgeInstallation.externalId, input.externalId), + ), + ) + .limit(1); + return row; + }; + + const existing = await find(); + if (existing) return refreshInstallation(database, existing, input); + + 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) { + 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. */ +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 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, + 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.externalId, + ], + 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'); + 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 { + return { + id: row.id, + organizationId: row.organizationId, + installationId: row.installationId, + provider: row.provider, + externalId: row.externalId, + owner: row.owner, + name: row.name, + // A hand-edited value reads as the mode that cannot write, as `repositories.ts` does. + mode: isMode(row.mode) ? row.mode : 'observe', + }; +} + +/** 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..b8067a8 --- /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, 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', + { + 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_external_unique').on( + table.organizationId, + table.provider, + table.externalId, + ), + index('organization_repository_installation_idx').on(table.installationId), + ], +);