Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion hub/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The team has chosen TypeScript, Express for the API, React and Vite for the fron

The API responds to a health check, and the frontend displays the logo with light and dark themes. Storybook keeps the existing components and pages available for reuse.

Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has no product models yet. Sign-in, deployment, and product APIs have not been built. This app cannot yet find repositories, import Builds, host previews, save product data, or support reviews.
Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The database has a provider-neutral identity transaction foundation, but it does not issue sessions or admit users to Hub. Sign-in, deployment, and product APIs have not been built. This app cannot yet find repositories, import Builds, host previews, save product data, or support reviews.

## Backend

Expand All @@ -24,6 +24,12 @@ The [background-job foundation](background-jobs.md) uses BullMQ with PostgreSQL

The [WebSocket foundation](websockets.md) attaches to the API's HTTP server and broadcasts to local channel subscribers without persistence. The application rejects upgrades until access checks are supplied. Product channels, client recovery, and communication between processes are not implemented.

The identity foundation stores Hub users, immutable external identity mappings, selected authentication provider revision digests, and bounded security audits. It does not store provider configuration. A provider adapter must validate its typed public configuration before the kernel computes a digest and starts the database transaction.

Domain operations receive only the transaction-scoped repositories they need. A successful state change and its success audit commit together. The kernel applies one fixed bounded retry policy to expected serializable and aggregate-creation races so consumers cannot disable convergence. A rejected database operation rolls back before its final denial or failure audit is written. External provider-validation failures write one typed audit after validation returns and before the operation fails. Audit callers select a typed kernel event; the repository builds the stored action and allowlisted context.

This foundation does not prove a provider assertion, create a usable Hub session, assign membership, or authorize product access.

## Frontend

```mermaid
Expand Down
8 changes: 8 additions & 0 deletions hub/docs/backend-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,11 @@ Add directories when their functions are needed. Do not add empty implementation
## Database access

The [shared Prisma client](database.md#shared-client) lives in `server/integrations/prisma/index.ts`. Domains may pass it to repositories for database queries without letting repositories import integrations. Keep model definitions, naming, and migrations consistent with the [database conventions](database.md#schema-conventions).

## Identity transactions

The identity kernel keeps database access in focused repositories for users, external identities, authentication provider revisions, and security audits. Its domain operations accept transaction-scoped repository functions rather than a mutable store or a global lock.

Keep network, provider, and filesystem work outside the transaction. Provider validation returns canonical public configuration, and the kernel computes and stores only its digest. A transaction may change one identity or authentication-provider aggregate and write its success audit.

The identity kernel owns one fixed bounded retry count and backoff for expected serialization and aggregate-creation conflicts around the complete transaction. Consumers cannot configure or disable this policy. Do not retry invariant or foreign-key failures. Write a database denial or failure audit only after the state transaction has rolled back and the operation has reached its terminal result. Write a typed provider-validation failure audit after external validation rejects and before returning the safe kernel error.
6 changes: 3 additions & 3 deletions hub/docs/database.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Hub database

Hub uses PostgreSQL and Prisma. Product models and migrations have not been added.
Hub uses PostgreSQL and Prisma.

[Background jobs](background-jobs.md) use the same database with BullMQ-owned tables and functions in the `bullmq` schema. Prisma does not own those objects. `npm run setup` applies Prisma migrations before BullMQ setup and schedule registration.

Expand Down Expand Up @@ -66,6 +66,6 @@ npm run db:generate

`npm ci` also generates the client. Generated TypeScript lives under the ignored `server/generated/prisma/` directory and compiles with the API. Schema validation and generation do not require a running database or `DATABASE_URL`; migration and database commands require both. Generate again after changing a schema.

Use `db:migrate` only with a local development database. It needs permission to create a shadow database to calculate migrations. `npm run db:deploy` applies committed migrations without creating new ones; it is not a production deployment setup. `npm run db:studio` opens Prisma's database editor and should be used only with an intended development database.
Use `db:migrate` only with a local development database. It needs permission to create a shadow database to calculate migrations. `npm run db:deploy` applies committed migrations without creating new ones; database tests use this command so constraints in committed migrations are exercised. It is not a production deployment setup. `npm run db:studio` opens Prisma's database editor and should be used only with an intended development database.

The [database-enabled test projects](testing-and-operations.md#parallel-database-tests) use separate databases for every project, worker, and test run. Setup applies the current Prisma schema directly and clears records before each case. Tests do not use the development database for records.
The [database-enabled test projects](testing-and-operations.md#parallel-database-tests) use separate databases for every project, worker, and test run. Setup deploys the committed migrations and clears records before each case. Tests do not use the development database for records.
2 changes: 1 addition & 1 deletion hub/docs/testing-and-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Database-enabled jsdom tests use the browser setup and database hooks together.

The database setup follows this lifecycle:

1. Before workers start, setup creates a template database with a unique name for each project and run. It applies the current Prisma schema with `prisma db push` and BullMQ's migrations, closes those setup connections, then copies the template for every worker slot.
1. Before workers start, setup creates a template database with a unique name for each project and run. It deploys the committed Prisma migrations and BullMQ's migrations, closes those setup connections, then copies the template for every worker slot.
2. Each worker receives its own `DATABASE_URL` before application modules load. Setup uses Vitest's [`VITEST_POOL_ID`](https://vitest.dev/guide/migration/jest#envs), which stays within the configured worker limit.
3. Before every case, setup empties application and job tables across user schemas and resets generated identity counters. It preserves `public._prisma_migrations` and `bullmq.migration`. Each case creates its own records; there is no shared seed data.
4. After the run, teardown drops only the databases it created, even if tests left connections open.
Expand Down
21 changes: 21 additions & 0 deletions hub/schema/authentication-provider-revision.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
model AuthenticationProviderRevision {
// ID columns
id String @id @default(uuid()) @db.Uuid
providerId String @map("provider_id") @db.Uuid

// Relations
provider AuthenticationProvider @relation("AuthenticationProviderRevisions", fields: [providerId], references: [id], onDelete: Restrict)
selectedBy AuthenticationProvider? @relation("SelectedAuthenticationProviderRevision")

// Attribute columns
revision Int
configurationDigest String @map("configuration_digest") @db.VarChar(64)

// Timestamp columns
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)

@@unique([providerId, revision])
@@index([providerId])
@@map("authentication_provider_revisions")
}
18 changes: 18 additions & 0 deletions hub/schema/authentication-provider.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
model AuthenticationProvider {
// ID columns
id String @id @default(uuid()) @db.Uuid
selectedRevisionId String? @unique @map("selected_revision_id") @db.Uuid

// Relations
revisions AuthenticationProviderRevision[] @relation("AuthenticationProviderRevisions")
selectedRevision AuthenticationProviderRevision? @relation("SelectedAuthenticationProviderRevision", fields: [selectedRevisionId], references: [id], onDelete: Restrict)

// Attribute columns
key String @unique @db.VarChar(100)

// Timestamp columns
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)

@@map("authentication_providers")
}
21 changes: 21 additions & 0 deletions hub/schema/external-identity.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
model ExternalIdentity {
// ID columns
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid

// Relations
user User @relation(fields: [userId], references: [id], onDelete: Restrict)

// Attribute columns
provider String @db.VarChar(100)
issuer String @db.VarChar(500)
subject String @db.VarChar(500)

// Timestamp columns
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)

@@unique([provider, issuer, subject])
@@index([userId])
@@map("external_identities")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
CREATE TYPE "SecurityAuditOutcome" AS ENUM ('SUCCESS', 'DENIED', 'FAILURE');

CREATE TABLE "users" (
"id" UUID NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);

CREATE TABLE "external_identities" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"provider" VARCHAR(100) NOT NULL,
"issuer" VARCHAR(500) NOT NULL,
"subject" VARCHAR(500) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "external_identities_pkey" PRIMARY KEY ("id"),
CONSTRAINT "external_identities_values_present" CHECK (
length("provider") > 0 AND length("issuer") > 0 AND length("subject") > 0
),
CONSTRAINT "external_identities_tuple_index_bytes" CHECK (
octet_length("provider") + octet_length("issuer") + octet_length("subject") + 2 <= 2000
)
);

CREATE TABLE "authentication_providers" (
"id" UUID NOT NULL,
"selected_revision_id" UUID,
"key" VARCHAR(100) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "authentication_providers_pkey" PRIMARY KEY ("id"),
CONSTRAINT "authentication_providers_key_present" CHECK (length("key") > 0)
);

CREATE TABLE "authentication_provider_revisions" (
"id" UUID NOT NULL,
"provider_id" UUID NOT NULL,
"revision" INTEGER NOT NULL,
"configuration_digest" VARCHAR(64) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "authentication_provider_revisions_pkey" PRIMARY KEY ("id"),
CONSTRAINT "authentication_provider_revisions_revision_positive" CHECK ("revision" > 0),
CONSTRAINT "authentication_provider_revisions_digest_sha256" CHECK (
"configuration_digest" ~ '^[0-9a-f]{64}$'
)
);

CREATE TABLE "security_audits" (
"id" UUID NOT NULL,
"action" VARCHAR(100) NOT NULL,
"outcome" "SecurityAuditOutcome",
"target_type" VARCHAR(100) NOT NULL,
"target_id" VARCHAR(100),
"context" JSONB NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "security_audits_pkey" PRIMARY KEY ("id"),
CONSTRAINT "security_audits_values_present" CHECK (
length("action") > 0 AND length("target_type") > 0
),
CONSTRAINT "security_audits_context_object" CHECK (jsonb_typeof("context") = 'object'),
CONSTRAINT "security_audits_context_bounded" CHECK (pg_column_size("context") <= 4096)
Comment thread
alliscode marked this conversation as resolved.
Resolved
);

CREATE UNIQUE INDEX "external_identities_provider_issuer_subject_key"
ON "external_identities"("provider", "issuer", "subject");
Comment thread
alliscode marked this conversation as resolved.
Resolved

CREATE INDEX "external_identities_user_id_idx" ON "external_identities"("user_id");

CREATE UNIQUE INDEX "authentication_providers_selected_revision_id_key"
ON "authentication_providers"("selected_revision_id");

CREATE UNIQUE INDEX "authentication_providers_key_key" ON "authentication_providers"("key");

CREATE UNIQUE INDEX "authentication_provider_revisions_provider_id_revision_key"
ON "authentication_provider_revisions"("provider_id", "revision");

CREATE INDEX "authentication_provider_revisions_provider_id_idx"
ON "authentication_provider_revisions"("provider_id");

CREATE INDEX "security_audits_action_created_at_idx"
ON "security_audits"("action", "created_at");

ALTER TABLE "external_identities"
ADD CONSTRAINT "external_identities_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

ALTER TABLE "authentication_provider_revisions"
ADD CONSTRAINT "authentication_provider_revisions_provider_id_fkey"
FOREIGN KEY ("provider_id") REFERENCES "authentication_providers"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;

ALTER TABLE "authentication_providers"
ADD CONSTRAINT "authentication_providers_selected_revision_id_fkey"
FOREIGN KEY ("selected_revision_id") REFERENCES "authentication_provider_revisions"("id")
ON DELETE RESTRICT ON UPDATE CASCADE
DEFERRABLE INITIALLY DEFERRED;

CREATE FUNCTION prevent_external_identity_changes()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'External identity records are immutable.';
END;
$$;

CREATE TRIGGER "external_identities_immutable"
BEFORE UPDATE OR DELETE ON "external_identities"
FOR EACH ROW EXECUTE FUNCTION prevent_external_identity_changes();

CREATE FUNCTION prevent_authentication_provider_revision_changes()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'Authentication provider revisions are immutable.';
END;
$$;

CREATE TRIGGER "authentication_provider_revisions_immutable"
BEFORE UPDATE OR DELETE ON "authentication_provider_revisions"
FOR EACH ROW EXECUTE FUNCTION prevent_authentication_provider_revision_changes();

CREATE FUNCTION prevent_security_audit_changes()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
RAISE EXCEPTION 'Security audit records are immutable.';
END;
$$;

CREATE TRIGGER "security_audits_immutable"
BEFORE UPDATE OR DELETE ON "security_audits"
FOR EACH ROW EXECUTE FUNCTION prevent_security_audit_changes();

CREATE FUNCTION enforce_selected_revision_provider()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
selected_revision UUID;
BEGIN
SELECT "selected_revision_id"
INTO selected_revision
FROM "authentication_providers"
WHERE "id" = NEW."id";

IF selected_revision IS NULL THEN
RAISE EXCEPTION 'Authentication provider must have a selected revision.';
END IF;

IF NOT EXISTS (
SELECT 1
FROM "authentication_provider_revisions"
WHERE "id" = selected_revision
AND "provider_id" = NEW."id"
) THEN
RAISE EXCEPTION 'Selected revision must belong to its authentication provider.';
END IF;
RETURN NEW;
END;
$$;

CREATE CONSTRAINT TRIGGER "authentication_provider_selected_revision_owner"
AFTER INSERT OR UPDATE OF "selected_revision_id" ON "authentication_providers"
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION enforce_selected_revision_provider();
1 change: 1 addition & 0 deletions hub/schema/migrations/migration_lock.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
provider = "postgresql"
5 changes: 5 additions & 0 deletions hub/schema/security-audit-outcome.enum.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
enum SecurityAuditOutcome {
SUCCESS
DENIED
FAILURE
}
18 changes: 18 additions & 0 deletions hub/schema/security-audit.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
model SecurityAudit {
// ID columns
id String @id @default(uuid()) @db.Uuid

// Attribute columns
action String @db.VarChar(100)
outcome SecurityAuditOutcome?
targetType String @map("target_type") @db.VarChar(100)
targetId String? @map("target_id") @db.VarChar(100)
context Json @db.JsonB

// Timestamp columns
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)

@@index([action, createdAt])
@@map("security_audits")
}
13 changes: 13 additions & 0 deletions hub/schema/user.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
model User {
// ID columns
id String @id @default(uuid()) @db.Uuid

// Relations
externalIdentities ExternalIdentity[]

// Timestamp columns
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)

@@map("users")
}
Loading
Loading