-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Plan 1+2 — Trust engine + Control-plane TS foundation #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
c63e6a1
docs: add Plan 2 — control-plane TS foundation + data model
162c186
chore(control-plane): scaffold TypeScript project with deps and direc…
bf1e24b
feat(control-plane): add centralized config module
42612f3
feat(control-plane): add Postgres pool + withTransaction helper
f6a6088
feat(control-plane): add migration runner with advisory lock + checks…
c97e24b
feat(control-plane): migration 001 — principals, principal_keys, issuers
2481334
feat(control-plane): migration 002 — attestations, network_scores, ne…
ddf7e8e
feat(control-plane): migration 003 — sync_events, bootstrap_issuers
4b7ea19
feat(control-plane): migration 004 — tenants, users, tenant_membershi…
ac3593b
feat(control-plane): migration 005 — policies, edge_nodes, sync_cursors
d959272
feat(control-plane): migration 006 — subscriptions, decisions, audit_log
0a3b504
feat(control-plane): add AppError, response helpers, defineHandler, l…
6e2875c
feat(control-plane): add requestTracker, auth (OIDC+API key), rateLim…
669d5c7
feat(control-plane): add principal/issuer registry domain (repository…
c31b1ba
feat(control-plane): add attestation domain (submit, dedup, lazy subj…
4bfb7e2
feat(control-plane): add sync event domain (append, snapshot, event s…
821812b
feat(control-plane): add route handlers for principals, attestations,…
d9aef82
feat(control-plane): add Express app composition + server entry point
80ff1d9
chore(control-plane): add jose dependency for JWT verification
8b8feab
fix(control-plane): address all 29 CodeRabbit review comments
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| # Database | ||
| DATABASE_URL=postgresql://verilink:verilink@localhost:5432/verilink | ||
|
|
||
| # Clerk OIDC | ||
| CLERK_ISSUER_URL=https://your-tenant.clerk.accounts.dev | ||
| CLERK_CLIENT_ID= | ||
| CLERK_CLIENT_SECRET= | ||
|
|
||
| # API key HMAC secret (generate: openssl rand -hex 32) | ||
| API_KEY_HMAC_SECRET= | ||
|
|
||
| # Trust engine gRPC | ||
| TRUST_ENGINE_ADDR=localhost:9091 | ||
|
|
||
| # Server | ||
| PORT=3000 | ||
| NODE_ENV=development | ||
| LOG_LEVEL=info | ||
|
|
||
| # Stripe | ||
| STRIPE_SECRET_KEY= | ||
| STRIPE_WEBHOOK_SECRET= |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| node_modules/ | ||
| dist/ | ||
| .env | ||
| .env.* | ||
| !.env.example | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| -- control-plane/migrations/001_graph/migration.sql | ||
|
|
||
| -- Unified principals: agents and issuers share one namespace. | ||
| CREATE TABLE principals ( | ||
| id TEXT PRIMARY KEY, -- vrl:p:<uuid> | ||
| entity_kind TEXT NOT NULL CHECK (entity_kind IN ('agent', 'issuer', 'both')), | ||
| name TEXT, | ||
| owner_tenant_id UUID, -- FK added in 004_tenancy | ||
| metadata JSONB DEFAULT '{}', | ||
| first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'deactivated')), | ||
| deactivated_at TIMESTAMPTZ | ||
| ); | ||
|
|
||
| -- assurance_level is DERIVED from principal_keys (has a non-revoked key with | ||
| -- control_verified_at set → verified_key; else unknown). Not a stored column. | ||
|
|
||
| -- Principal keys (rotation/history) | ||
| CREATE TABLE principal_keys ( | ||
| principal_id TEXT NOT NULL REFERENCES principals(id), | ||
| key_id TEXT NOT NULL, -- e.g. k1 | ||
| public_key_raw BYTEA NOT NULL, -- raw 32-byte Ed25519 public key | ||
| public_key_jwk JSONB NOT NULL, -- did:key verification method form | ||
| key_hash TEXT NOT NULL, -- sha256(public_key_raw); indexed for lookup | ||
| control_verified_at TIMESTAMPTZ, -- set when the principal proved control of this key | ||
| valid_from TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| valid_until TIMESTAMPTZ, -- null = current | ||
| revoked_at TIMESTAMPTZ, | ||
| revocation_reason TEXT, | ||
| PRIMARY KEY (principal_id, key_id) | ||
| ); | ||
|
|
||
| -- A public key is globally unique by key_hash: one key belongs to at most | ||
| -- one principal, even across rotation/validity windows. | ||
| CREATE UNIQUE INDEX key_hash_unique ON principal_keys (key_hash); | ||
|
|
||
| -- Issuer attributes (a principal that can sign attestations) | ||
| CREATE TABLE issuers ( | ||
| principal_id TEXT PRIMARY KEY REFERENCES principals(id), | ||
| trust_weight NUMERIC(3,2) NOT NULL DEFAULT 1.0 CHECK (trust_weight >= 0), -- issuer-quality knob; NOT touched by bootstrap de-emphasis | ||
| is_bootstrap BOOLEAN DEFAULT false, -- derived from bootstrap_issuers by the seeder | ||
| verified_at TIMESTAMPTZ, -- set after proof of key control + review | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now() | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| -- control-plane/migrations/002_attestations/migration.sql | ||
|
|
||
| -- Attestations: signed behavioral reports (global) | ||
| CREATE TABLE attestations ( | ||
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||
| issuer_id TEXT NOT NULL REFERENCES issuers(principal_id), | ||
| subject_id TEXT NOT NULL REFERENCES principals(id), | ||
| jws_token TEXT NOT NULL, | ||
| token_digest TEXT NOT NULL UNIQUE, -- sha256(jws_token); dedup | ||
| payload JSONB NOT NULL, | ||
| facts JSONB NOT NULL, -- shareable facts (public or participants) | ||
| facts_hash TEXT NOT NULL, -- sha256(RFC 8785 JCS(facts)); exact-content identity | ||
| visibility TEXT NOT NULL DEFAULT 'participants' CHECK (visibility IN ('participants', 'public')), | ||
| trust_delta INTEGER NOT NULL CHECK (trust_delta BETWEEN -100 AND 100), | ||
| attestation_type TEXT NOT NULL, | ||
| schema_version TEXT NOT NULL, | ||
| jti TEXT, | ||
| observation_id TEXT, -- for split-visibility pairing; null = no pairing | ||
| issued_at TIMESTAMPTZ NOT NULL, | ||
| expires_at TIMESTAMPTZ, | ||
| superseded_by UUID REFERENCES attestations(id), | ||
| sig_verified BOOLEAN NOT NULL DEFAULT true, | ||
| verified_key_id TEXT NOT NULL, -- which key verified (from VerifyResult) | ||
| received_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| CHECK ( | ||
| (attestation_type = 'negative_incident' AND trust_delta < 0) | ||
| OR (attestation_type <> 'negative_incident' AND trust_delta >= 0) | ||
| ), | ||
| -- Composite FK: the verified key belongs to the issuer | ||
| FOREIGN KEY (issuer_id, verified_key_id) REFERENCES principal_keys(principal_id, key_id) | ||
| ); | ||
|
|
||
| CREATE INDEX idx_attestations_subject ON attestations (subject_id); | ||
| CREATE INDEX idx_attestations_issuer ON attestations (issuer_id); | ||
| CREATE INDEX idx_attestations_issued_at ON attestations (issued_at); | ||
|
|
||
| -- Network scores: materialized VeriRank output (global) | ||
| CREATE TABLE network_scores ( | ||
| principal_id TEXT NOT NULL REFERENCES principals(id) ON DELETE CASCADE, | ||
| entity_kind TEXT NOT NULL, | ||
| score INTEGER NOT NULL, | ||
| blacklisted BOOLEAN NOT NULL DEFAULT false, | ||
| score_reason TEXT NOT NULL CHECK (score_reason IN ('propagated', 'blacklisted')), | ||
| computed_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| sync_version BIGINT NOT NULL, | ||
| PRIMARY KEY (principal_id) | ||
| ); | ||
|
|
||
| -- Score history: one row per principal per score CHANGE | ||
| CREATE TABLE network_score_history ( | ||
| principal_id TEXT NOT NULL REFERENCES principals(id) ON DELETE CASCADE, | ||
| score INTEGER NOT NULL, | ||
| blacklisted BOOLEAN NOT NULL, | ||
| score_reason TEXT NOT NULL, | ||
| computed_at TIMESTAMPTZ NOT NULL, | ||
| sync_version BIGINT NOT NULL, | ||
| PRIMARY KEY (principal_id, sync_version) | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| -- control-plane/migrations/003_sync/migration.sql | ||
|
|
||
| -- Sync event log (unified, transactionally safe) | ||
| CREATE TABLE sync_events ( | ||
| sync_version BIGINT PRIMARY KEY, -- allocated by the locked allocator, in-commit-order | ||
| event_type TEXT NOT NULL CHECK (event_type IN ( | ||
| 'score.upsert', 'score.delete', 'key.upsert', 'key.revoke', 'policy.replace' | ||
| )), | ||
| principal_id TEXT, | ||
| tenant_id UUID, | ||
| payload JSONB NOT NULL, | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now() | ||
| ); | ||
|
|
||
| CREATE INDEX idx_sync_events_tenant ON sync_events (tenant_id) WHERE tenant_id IS NOT NULL; | ||
|
|
||
| -- Bootstrap registry (issuers only — roots are always issuers) | ||
| CREATE TABLE bootstrap_issuers ( | ||
| principal_id TEXT PRIMARY KEY REFERENCES issuers(principal_id), | ||
| name TEXT NOT NULL, | ||
| current_weight NUMERIC(3,2) NOT NULL DEFAULT 1.0 CHECK (current_weight >= 0), -- written through to Root.weight | ||
| seeded_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| de_emphasized_at TIMESTAMPTZ, | ||
| de_emphasis_reason TEXT, | ||
| approved_by UUID | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| -- control-plane/migrations/004_tenancy/migration.sql | ||
|
|
||
| CREATE EXTENSION IF NOT EXISTS citext; | ||
|
|
||
| -- Tenants | ||
| CREATE TABLE tenants ( | ||
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||
| slug TEXT UNIQUE NOT NULL, | ||
| name TEXT NOT NULL, | ||
| plan TEXT NOT NULL DEFAULT 'free', | ||
| status TEXT NOT NULL DEFAULT 'active', | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now() | ||
| ); | ||
|
|
||
| -- Global users | ||
| CREATE TABLE users ( | ||
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||
| email CITEXT UNIQUE NOT NULL, | ||
| oidc_issuer TEXT NOT NULL, | ||
| oidc_subject TEXT NOT NULL, | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| UNIQUE (oidc_issuer, oidc_subject) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| ); | ||
|
|
||
| -- Tenant memberships | ||
| CREATE TABLE tenant_memberships ( | ||
| user_id UUID NOT NULL REFERENCES users(id), | ||
| tenant_id UUID NOT NULL REFERENCES tenants(id), | ||
| role TEXT NOT NULL DEFAULT 'member', | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| PRIMARY KEY (user_id, tenant_id) | ||
| ); | ||
|
|
||
| -- API keys — HMAC-SHA256. Format: vrl_ + exactly 64 lowercase hex. | ||
| CREATE TABLE api_keys ( | ||
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||
| tenant_id UUID NOT NULL REFERENCES tenants(id), | ||
| key_prefix TEXT NOT NULL, | ||
| key_hash_hmac TEXT NOT NULL, | ||
| scopes TEXT[] NOT NULL DEFAULT '{}', | ||
| last_used_at TIMESTAMPTZ, | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| revoked_at TIMESTAMPTZ, | ||
| UNIQUE (tenant_id, id) | ||
| ); | ||
|
|
||
| -- Now add the FK from principals to tenants | ||
| ALTER TABLE principals | ||
| ADD CONSTRAINT fk_principals_owner_tenant | ||
| FOREIGN KEY (owner_tenant_id) REFERENCES tenants(id); | ||
|
|
||
| -- Now add the FK from bootstrap_issuers to users | ||
| ALTER TABLE bootstrap_issuers | ||
| ADD CONSTRAINT fk_bootstrap_approved_by | ||
| FOREIGN KEY (approved_by) REFERENCES users(id); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| -- control-plane/migrations/005_policy/migration.sql | ||
|
|
||
| -- Policies: per-tenant threshold + actions | ||
| CREATE TABLE policies ( | ||
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||
| tenant_id UUID NOT NULL REFERENCES tenants(id), | ||
| name TEXT NOT NULL, | ||
| threshold INTEGER NOT NULL DEFAULT 50, | ||
| below_threshold_action TEXT NOT NULL DEFAULT 'deny' CHECK (below_threshold_action IN ('allow', 'deny')), | ||
| unsigned_action TEXT NOT NULL DEFAULT 'passthrough' CHECK (unsigned_action IN ('passthrough', 'deny')), | ||
| allow_fingerprints TEXT[] DEFAULT '{}', | ||
| deny_fingerprints TEXT[] DEFAULT '{}', | ||
| fail_open_expired BOOLEAN NOT NULL DEFAULT false, | ||
| no_drop_decisions BOOLEAN NOT NULL DEFAULT false, | ||
| max_snapshot_age_seconds INTEGER NOT NULL DEFAULT 300 CHECK (max_snapshot_age_seconds >= 0), | ||
| allow_sample_rate NUMERIC(4,3) NOT NULL DEFAULT 0.010 CHECK (allow_sample_rate >= 0 AND allow_sample_rate <= 1), | ||
| is_active BOOLEAN NOT NULL DEFAULT true, | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| UNIQUE (tenant_id, name) | ||
| ); | ||
|
|
||
| -- One active policy per tenant (partial unique index) | ||
| CREATE UNIQUE INDEX active_policy_per_tenant ON policies (tenant_id) WHERE is_active; | ||
|
|
||
| -- Edge nodes | ||
| CREATE TABLE edge_nodes ( | ||
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||
| tenant_id UUID NOT NULL REFERENCES tenants(id), | ||
| name TEXT NOT NULL, | ||
| api_key_id UUID, | ||
| last_seen_at TIMESTAMPTZ, | ||
| last_sync_version BIGINT, | ||
| status TEXT NOT NULL DEFAULT 'unknown', | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| UNIQUE (tenant_id, id), | ||
| FOREIGN KEY (tenant_id, api_key_id) REFERENCES api_keys(tenant_id, id) | ||
| ); | ||
|
|
||
| -- Sync cursors | ||
| CREATE TABLE sync_cursors ( | ||
| tenant_id UUID NOT NULL, | ||
| edge_node_id UUID NOT NULL, | ||
| last_cursor BIGINT NOT NULL DEFAULT 0, | ||
| last_sync_at TIMESTAMPTZ, | ||
| snapshot_hash TEXT, | ||
| PRIMARY KEY (tenant_id, edge_node_id), | ||
| FOREIGN KEY (tenant_id, edge_node_id) REFERENCES edge_nodes(tenant_id, id) | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| -- control-plane/migrations/006_audit/migration.sql | ||
|
|
||
| -- Subscriptions (Stripe) | ||
| CREATE TABLE subscriptions ( | ||
| id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | ||
| tenant_id UUID NOT NULL REFERENCES tenants(id), | ||
| stripe_customer_id TEXT NOT NULL, | ||
| stripe_subscription_id TEXT NOT NULL, | ||
| plan TEXT NOT NULL, | ||
| status TEXT NOT NULL, | ||
| current_period_end TIMESTAMPTZ, | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now() | ||
| ); | ||
|
|
||
| -- Stripe webhook event dedup (global) | ||
| CREATE TABLE stripe_webhook_events ( | ||
| id TEXT PRIMARY KEY, | ||
| received_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| processed_at TIMESTAMPTZ, | ||
| payload JSONB NOT NULL | ||
| ); | ||
|
|
||
| -- Decision aggregates: per-minute rollup | ||
| CREATE TABLE decision_aggregates ( | ||
| id BIGSERIAL PRIMARY KEY, | ||
| tenant_id UUID NOT NULL REFERENCES tenants(id), | ||
| edge_node_id UUID NOT NULL, | ||
| bucket_minute TIMESTAMPTZ NOT NULL, | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| dimension_kind TEXT NOT NULL CHECK (dimension_kind IN ('all', 'principal', 'fingerprint')), | ||
| dimension_value TEXT NOT NULL, -- '' for 'all'; the principal_id or fingerprint otherwise | ||
| action TEXT NOT NULL CHECK (action IN ('allow', 'deny', 'passthrough')), | ||
| count INTEGER NOT NULL CHECK (count >= 0), | ||
| UNIQUE (tenant_id, edge_node_id, bucket_minute, dimension_kind, dimension_value, action), | ||
| FOREIGN KEY (tenant_id, edge_node_id) REFERENCES edge_nodes(tenant_id, id) | ||
| ); | ||
|
|
||
| -- Decision samples: all denies + tunable % of allows/passthroughs | ||
| CREATE TABLE decision_samples ( | ||
| id BIGSERIAL PRIMARY KEY, | ||
| tenant_id UUID NOT NULL REFERENCES tenants(id), | ||
| edge_node_id UUID NOT NULL, | ||
| wal_seq BIGINT NOT NULL, | ||
| fingerprint TEXT NOT NULL, | ||
| principal_id TEXT, | ||
| score INTEGER, | ||
| blacklisted BOOLEAN, | ||
| score_reason TEXT, | ||
| action TEXT NOT NULL CHECK (action IN ('allow', 'deny', 'passthrough')), | ||
| decided_at TIMESTAMPTZ NOT NULL, | ||
| FOREIGN KEY (tenant_id, edge_node_id) REFERENCES edge_nodes(tenant_id, id), | ||
| received_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| UNIQUE (edge_node_id, wal_seq) | ||
| ); | ||
|
|
||
| -- Batch receipt (idempotent delivery) | ||
| CREATE TABLE decision_batches ( | ||
| edge_node_id UUID NOT NULL, | ||
| batch_id UUID NOT NULL, | ||
| tenant_id UUID NOT NULL REFERENCES tenants(id), | ||
| first_wal_seq BIGINT NOT NULL, | ||
| last_wal_seq BIGINT NOT NULL, | ||
| payload_hash TEXT NOT NULL, -- sha256(batch payload) | ||
| received_at TIMESTAMPTZ NOT NULL DEFAULT now(), | ||
| PRIMARY KEY (edge_node_id, batch_id), | ||
| CHECK (first_wal_seq <= last_wal_seq), | ||
| FOREIGN KEY (tenant_id, edge_node_id) REFERENCES edge_nodes(tenant_id, id) | ||
| ); | ||
|
|
||
| -- Audit log: administrative/state-change events only (low volume) | ||
| CREATE TABLE audit_log ( | ||
| id BIGSERIAL PRIMARY KEY, | ||
| tenant_id UUID NOT NULL REFERENCES tenants(id), | ||
| actor_type TEXT NOT NULL, | ||
| actor_id TEXT, | ||
| action TEXT NOT NULL, | ||
| resource TEXT NOT NULL, | ||
| resource_id TEXT, | ||
| metadata JSONB DEFAULT '{}', | ||
| ip TEXT, | ||
| user_agent TEXT, | ||
| created_at TIMESTAMPTZ NOT NULL DEFAULT now() | ||
| ); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.