diff --git a/migrations/0053_installation_scoped_tenant_state.sql b/migrations/0053_installation_scoped_tenant_state.sql new file mode 100644 index 0000000000..57c0c417e6 --- /dev/null +++ b/migrations/0053_installation_scoped_tenant_state.sql @@ -0,0 +1,135 @@ +-- Multi-tenant hosted productization (#1028): repo settings + BYOK must isolate by installation, not only by +-- repo_full_name. Rebuild both tables with an installation_id discriminator and migrate legacy rows into the +-- null-installation lane for self-host / pre-hosted compatibility. + +ALTER TABLE repository_settings RENAME TO repository_settings_legacy; + +CREATE TABLE repository_settings ( + repo_full_name TEXT NOT NULL, + installation_id INTEGER NOT NULL DEFAULT 0, + comment_mode TEXT NOT NULL DEFAULT 'detected_contributors_only', + public_audience_mode TEXT NOT NULL DEFAULT 'oss_maintainer', + public_signal_level TEXT NOT NULL DEFAULT 'standard', + check_run_mode TEXT NOT NULL DEFAULT 'off', + check_run_detail_level TEXT NOT NULL DEFAULT 'minimal', + gate_check_mode TEXT NOT NULL DEFAULT 'off', + gate_pack TEXT NOT NULL DEFAULT 'gittensor', + linked_issue_gate_mode TEXT NOT NULL DEFAULT 'block', + duplicate_pr_gate_mode TEXT NOT NULL DEFAULT 'block', + quality_gate_mode TEXT NOT NULL DEFAULT 'advisory', + quality_gate_min_score INTEGER, + slop_gate_mode TEXT NOT NULL DEFAULT 'off', + merge_readiness_gate_mode TEXT NOT NULL DEFAULT 'off', + manifest_policy_gate_mode TEXT NOT NULL DEFAULT 'off', + first_time_contributor_grace INTEGER NOT NULL DEFAULT 0, + slop_gate_min_score INTEGER, + slop_ai_advisory INTEGER NOT NULL DEFAULT 0, + ai_review_mode TEXT NOT NULL DEFAULT 'off', + ai_review_byok INTEGER NOT NULL DEFAULT 0, + ai_review_provider TEXT, + ai_review_model TEXT, + auto_label_enabled INTEGER NOT NULL DEFAULT 1, + gittensor_label TEXT NOT NULL DEFAULT 'gittensor', + create_missing_label INTEGER NOT NULL DEFAULT 1, + public_surface TEXT NOT NULL DEFAULT 'comment_and_label', + include_maintainer_authors INTEGER NOT NULL DEFAULT 0, + require_linked_issue INTEGER NOT NULL DEFAULT 0, + backfill_enabled INTEGER NOT NULL DEFAULT 1, + private_trust_enabled INTEGER NOT NULL DEFAULT 1, + badge_enabled INTEGER NOT NULL DEFAULT 0, + command_authorization_json TEXT NOT NULL DEFAULT '{}', + autonomy_json TEXT NOT NULL DEFAULT '{}', + auto_maintain_json TEXT NOT NULL DEFAULT '{}', + agent_paused INTEGER NOT NULL DEFAULT 0, + agent_dry_run INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO repository_settings ( + repo_full_name, installation_id, comment_mode, public_audience_mode, public_signal_level, check_run_mode, + check_run_detail_level, gate_check_mode, gate_pack, linked_issue_gate_mode, duplicate_pr_gate_mode, + quality_gate_mode, quality_gate_min_score, slop_gate_mode, merge_readiness_gate_mode, + manifest_policy_gate_mode, first_time_contributor_grace, slop_gate_min_score, slop_ai_advisory, + ai_review_mode, ai_review_byok, ai_review_provider, ai_review_model, auto_label_enabled, gittensor_label, + create_missing_label, public_surface, include_maintainer_authors, require_linked_issue, backfill_enabled, + private_trust_enabled, badge_enabled, command_authorization_json, autonomy_json, auto_maintain_json, + agent_paused, agent_dry_run, created_at, updated_at +) +SELECT + repo_full_name, 0, comment_mode, + COALESCE(public_audience_mode, 'oss_maintainer'), + COALESCE(public_signal_level, 'standard'), + COALESCE(check_run_mode, 'off'), + COALESCE(check_run_detail_level, 'minimal'), + COALESCE(gate_check_mode, 'off'), + COALESCE(gate_pack, 'gittensor'), + COALESCE(linked_issue_gate_mode, 'block'), + COALESCE(duplicate_pr_gate_mode, 'block'), + COALESCE(quality_gate_mode, 'advisory'), + quality_gate_min_score, + COALESCE(slop_gate_mode, 'off'), + COALESCE(merge_readiness_gate_mode, 'off'), + COALESCE(manifest_policy_gate_mode, 'off'), + COALESCE(first_time_contributor_grace, 0), + slop_gate_min_score, + COALESCE(slop_ai_advisory, 0), + COALESCE(ai_review_mode, 'off'), + COALESCE(ai_review_byok, 0), + ai_review_provider, + ai_review_model, + COALESCE(auto_label_enabled, 1), + COALESCE(gittensor_label, 'gittensor'), + COALESCE(create_missing_label, 1), + COALESCE(public_surface, 'comment_and_label'), + COALESCE(include_maintainer_authors, 0), + COALESCE(require_linked_issue, 0), + COALESCE(backfill_enabled, 1), + COALESCE(private_trust_enabled, 1), + COALESCE(badge_enabled, 0), + COALESCE(command_authorization_json, '{}'), + COALESCE(autonomy_json, '{}'), + COALESCE(auto_maintain_json, '{}'), + COALESCE(agent_paused, 0), + COALESCE(agent_dry_run, 0), + created_at, + updated_at +FROM repository_settings_legacy; + +DROP TABLE repository_settings_legacy; + +CREATE UNIQUE INDEX repository_settings_repo_installation_unique + ON repository_settings (repo_full_name, installation_id); +CREATE INDEX repository_settings_repo_updated_idx + ON repository_settings (repo_full_name, updated_at); + +ALTER TABLE repository_ai_keys RENAME TO repository_ai_keys_legacy; + +CREATE TABLE repository_ai_keys ( + repo_full_name TEXT NOT NULL, + installation_id INTEGER NOT NULL DEFAULT 0, + provider TEXT NOT NULL, + ciphertext TEXT NOT NULL, + iv TEXT NOT NULL, + salt TEXT, + key_version INTEGER NOT NULL DEFAULT 1, + model TEXT, + last4 TEXT NOT NULL, + created_by TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO repository_ai_keys ( + repo_full_name, installation_id, provider, ciphertext, iv, salt, key_version, model, last4, created_by, created_at, updated_at +) +SELECT + repo_full_name, 0, provider, ciphertext, iv, salt, COALESCE(key_version, 1), model, last4, created_by, created_at, updated_at +FROM repository_ai_keys_legacy; + +DROP TABLE repository_ai_keys_legacy; + +CREATE UNIQUE INDEX repository_ai_keys_repo_installation_unique + ON repository_ai_keys (repo_full_name, installation_id); +CREATE INDEX repository_ai_keys_repo_updated_idx + ON repository_ai_keys (repo_full_name, updated_at); diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 6af5023af7..40569423da 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -229,7 +229,7 @@ export async function markInstallationDeleted(env: Env, installationId: number): await db.update(installations).set({ suspendedAt: nowIso(), updatedAt: nowIso() }).where(eq(installations.id, installationId)); await db .update(repositories) - .set({ isInstalled: false, installationId: null, updatedAt: nowIso() }) + .set({ isInstalled: false, installationId: sql`null`, updatedAt: nowIso() }) .where(eq(repositories.installationId, installationId)); } @@ -239,7 +239,7 @@ export async function markRepositoriesRemovedFromInstallation(env: Env, installa const db = getDb(env.DB); await db .update(repositories) - .set({ isInstalled: false, installationId: null, updatedAt: nowIso() }) + .set({ isInstalled: false, installationId: sql`null`, updatedAt: nowIso() }) .where(and(eq(repositories.installationId, installationId), inArray(repositories.fullName, names))); } @@ -397,10 +397,17 @@ export async function listRepositories(env: Env): Promise { export async function getRepositorySettings(env: Env, fullName: string): Promise { const db = getDb(env.DB); - const [row] = await db.select().from(repositorySettings).where(eq(repositorySettings.repoFullName, fullName)).limit(1); - if (!row) { + const installationId = await resolveScopedInstallationId(env, fullName); + const [row] = await db + .select() + .from(repositorySettings) + .where(and(eq(repositorySettings.repoFullName, fullName), eq(repositorySettings.installationId, installationId))) + .limit(1); + const effectiveRow = row; + if (!effectiveRow) { return { repoFullName: fullName, + installationId, commentMode: "detected_contributors_only", publicAudienceMode: "oss_maintainer", publicSignalLevel: "standard", @@ -439,50 +446,53 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise }; } return { - repoFullName: row.repoFullName, - commentMode: parseCommentMode(row.commentMode), - publicAudienceMode: parsePublicAudienceMode(row.publicAudienceMode), - publicSignalLevel: row.publicSignalLevel === "minimal" ? "minimal" : "standard", - checkRunMode: parseCheckRunMode(row.checkRunMode), - checkRunDetailLevel: parseCheckRunDetailLevel(row.checkRunDetailLevel), - gateCheckMode: parseGateCheckMode(row.gateCheckMode), - gatePack: parseGatePack(row.gatePack), - linkedIssueGateMode: parseGateRuleMode(row.linkedIssueGateMode), - duplicatePrGateMode: parseGateRuleMode(row.duplicatePrGateMode), - qualityGateMode: parseGateRuleMode(row.qualityGateMode), - qualityGateMinScore: normalizeQualityGateMinScore(row.qualityGateMinScore), - slopGateMode: parseGateRuleMode(row.slopGateMode), - mergeReadinessGateMode: parseGateRuleMode(row.mergeReadinessGateMode), - manifestPolicyGateMode: parseGateRuleMode(row.manifestPolicyGateMode), - firstTimeContributorGrace: row.firstTimeContributorGrace, - slopGateMinScore: normalizeQualityGateMinScore(row.slopGateMinScore), - slopAiAdvisory: row.slopAiAdvisory, - aiReviewMode: parseGateRuleMode(row.aiReviewMode), - aiReviewByok: row.aiReviewByok, - aiReviewProvider: normalizeAiReviewProvider(row.aiReviewProvider), - aiReviewModel: row.aiReviewModel ?? null, - autoLabelEnabled: row.autoLabelEnabled, - gittensorLabel: row.gittensorLabel, - createMissingLabel: row.createMissingLabel, - publicSurface: parsePublicSurface(row.publicSurface), - includeMaintainerAuthors: row.includeMaintainerAuthors, - requireLinkedIssue: row.requireLinkedIssue, - backfillEnabled: row.backfillEnabled, - privateTrustEnabled: row.privateTrustEnabled, - badgeEnabled: row.badgeEnabled, - agentPaused: row.agentPaused, - agentDryRun: row.agentDryRun, - commandAuthorization: parseCommandAuthorizationPolicy(row.commandAuthorizationJson), - autonomy: parseAutonomyPolicy(row.autonomyJson), - autoMaintain: parseAutoMaintainPolicy(row.autoMaintainJson), - createdAt: row.createdAt, - updatedAt: row.updatedAt, + repoFullName: effectiveRow.repoFullName, + installationId: effectiveRow.installationId ?? installationId, + commentMode: parseCommentMode(effectiveRow.commentMode), + publicAudienceMode: parsePublicAudienceMode(effectiveRow.publicAudienceMode), + publicSignalLevel: effectiveRow.publicSignalLevel === "minimal" ? "minimal" : "standard", + checkRunMode: parseCheckRunMode(effectiveRow.checkRunMode), + checkRunDetailLevel: parseCheckRunDetailLevel(effectiveRow.checkRunDetailLevel), + gateCheckMode: parseGateCheckMode(effectiveRow.gateCheckMode), + gatePack: parseGatePack(effectiveRow.gatePack), + linkedIssueGateMode: parseGateRuleMode(effectiveRow.linkedIssueGateMode), + duplicatePrGateMode: parseGateRuleMode(effectiveRow.duplicatePrGateMode), + qualityGateMode: parseGateRuleMode(effectiveRow.qualityGateMode), + qualityGateMinScore: normalizeQualityGateMinScore(effectiveRow.qualityGateMinScore), + slopGateMode: parseGateRuleMode(effectiveRow.slopGateMode), + mergeReadinessGateMode: parseGateRuleMode(effectiveRow.mergeReadinessGateMode), + manifestPolicyGateMode: parseGateRuleMode(effectiveRow.manifestPolicyGateMode), + firstTimeContributorGrace: effectiveRow.firstTimeContributorGrace, + slopGateMinScore: normalizeQualityGateMinScore(effectiveRow.slopGateMinScore), + slopAiAdvisory: effectiveRow.slopAiAdvisory, + aiReviewMode: parseGateRuleMode(effectiveRow.aiReviewMode), + aiReviewByok: effectiveRow.aiReviewByok, + aiReviewProvider: normalizeAiReviewProvider(effectiveRow.aiReviewProvider), + aiReviewModel: effectiveRow.aiReviewModel ?? null, + autoLabelEnabled: effectiveRow.autoLabelEnabled, + gittensorLabel: effectiveRow.gittensorLabel, + createMissingLabel: effectiveRow.createMissingLabel, + publicSurface: parsePublicSurface(effectiveRow.publicSurface), + includeMaintainerAuthors: effectiveRow.includeMaintainerAuthors, + requireLinkedIssue: effectiveRow.requireLinkedIssue, + backfillEnabled: effectiveRow.backfillEnabled, + privateTrustEnabled: effectiveRow.privateTrustEnabled, + badgeEnabled: effectiveRow.badgeEnabled, + agentPaused: effectiveRow.agentPaused, + agentDryRun: effectiveRow.agentDryRun, + commandAuthorization: parseCommandAuthorizationPolicy(effectiveRow.commandAuthorizationJson), + autonomy: parseAutonomyPolicy(effectiveRow.autonomyJson), + autoMaintain: parseAutoMaintainPolicy(effectiveRow.autoMaintainJson), + createdAt: effectiveRow.createdAt, + updatedAt: effectiveRow.updatedAt, }; } export async function upsertRepositorySettings(env: Env, settings: Partial & { repoFullName: string }): Promise { + const installationId = settings.installationId ?? (await resolveScopedInstallationId(env, settings.repoFullName)); const resolved: RepositorySettings = { repoFullName: settings.repoFullName, + installationId, commentMode: settings.commentMode ?? "detected_contributors_only", publicAudienceMode: settings.publicAudienceMode ?? "oss_maintainer", publicSignalLevel: settings.publicSignalLevel ?? "standard", @@ -524,6 +534,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial { const db = getDb(env.DB); - const [row] = await db.select().from(repositoryAiKeys).where(eq(repositoryAiKeys.repoFullName, fullName)).limit(1); - if (!row) return { configured: false }; - return { configured: true, provider: normalizeAiKeyProvider(row.provider), last4: row.last4, model: row.model ?? null, createdBy: row.createdBy, updatedAt: row.updatedAt }; + const installationId = await resolveScopedInstallationId(env, fullName); + const [row] = await db + .select() + .from(repositoryAiKeys) + .where(and(eq(repositoryAiKeys.repoFullName, fullName), eq(repositoryAiKeys.installationId, installationId))) + .limit(1); + const effectiveRow = row; + if (!effectiveRow) return { configured: false }; + return { configured: true, provider: normalizeAiKeyProvider(effectiveRow.provider), last4: effectiveRow.last4, model: effectiveRow.model ?? null, createdBy: effectiveRow.createdBy, updatedAt: effectiveRow.updatedAt }; } /** @@ -638,11 +655,12 @@ export async function getRepositoryAiKeyStatus(env: Env, fullName: string): Prom */ export async function upsertRepositoryAiKey( env: Env, - input: { repoFullName: string; provider: AiKeyProvider; key: string; model?: string | null; createdBy?: string | null }, + input: { repoFullName: string; provider: AiKeyProvider; key: string; model?: string | null; createdBy?: string | null; installationId?: number | null | undefined }, ): Promise { const secret = env.TOKEN_ENCRYPTION_SECRET; if (!secret) throw new Error("missing_encryption_secret"); const trimmedKey = input.key.trim(); + const installationId = input.installationId ?? (await resolveScopedInstallationId(env, input.repoFullName)); const existing = await getRepositoryAiKeyStatus(env, input.repoFullName); const { ciphertext, iv, salt, version } = await encryptSecret(trimmedKey, secret); const last4 = trimmedKey.slice(-4); @@ -652,9 +670,9 @@ export async function upsertRepositoryAiKey( const db = getDb(env.DB); await db .insert(repositoryAiKeys) - .values({ repoFullName: input.repoFullName, provider: input.provider, ciphertext, iv, salt, keyVersion: version, model, last4, createdBy, updatedAt }) + .values({ repoFullName: input.repoFullName, installationId: installationId ?? 0, provider: input.provider, ciphertext, iv, salt, keyVersion: version, model, last4, createdBy, updatedAt }) .onConflictDoUpdate({ - target: repositoryAiKeys.repoFullName, + target: [repositoryAiKeys.repoFullName, repositoryAiKeys.installationId], set: { provider: input.provider, ciphertext, iv, salt, keyVersion: version, model, last4, createdBy, updatedAt }, }); await recordAiKeyChange(env, { repoFullName: input.repoFullName, action: existing.configured ? "replace" : "set", provider: input.provider, last4, actor: createdBy }); @@ -664,8 +682,9 @@ export async function upsertRepositoryAiKey( /** Remove a repo's BYOK key. Records a lifecycle audit event when a key was actually present. */ export async function deleteRepositoryAiKey(env: Env, fullName: string, actor?: string | null): Promise { const existing = await getRepositoryAiKeyStatus(env, fullName); + const installationId = await resolveScopedInstallationId(env, fullName); const db = getDb(env.DB); - await db.delete(repositoryAiKeys).where(eq(repositoryAiKeys.repoFullName, fullName)); + await db.delete(repositoryAiKeys).where(and(eq(repositoryAiKeys.repoFullName, fullName), eq(repositoryAiKeys.installationId, installationId))); if (existing.configured) { await recordAiKeyChange(env, { repoFullName: fullName, action: "delete", provider: existing.provider, last4: existing.last4, actor: actor ?? null }); } @@ -701,16 +720,27 @@ export async function getDecryptedRepositoryAiKey(env: Env, fullName: string): P const secret = env.TOKEN_ENCRYPTION_SECRET; if (!secret) return null; const db = getDb(env.DB); - const [row] = await db.select().from(repositoryAiKeys).where(eq(repositoryAiKeys.repoFullName, fullName)).limit(1); - if (!row) return null; + const installationId = await resolveScopedInstallationId(env, fullName); + const [row] = await db + .select() + .from(repositoryAiKeys) + .where(and(eq(repositoryAiKeys.repoFullName, fullName), eq(repositoryAiKeys.installationId, installationId))) + .limit(1); + const effectiveRow = row; + if (!effectiveRow) return null; try { - const key = await decryptSecret(row.ciphertext, row.iv, secret, row.salt); - return { provider: normalizeAiKeyProvider(row.provider), key, model: row.model ?? null }; + const key = await decryptSecret(effectiveRow.ciphertext, effectiveRow.iv, secret, effectiveRow.salt); + return { provider: normalizeAiKeyProvider(effectiveRow.provider), key, model: effectiveRow.model ?? null }; } catch { return null; } } +async function resolveScopedInstallationId(env: Env, repoFullName: string): Promise { + const repo = await getRepository(env, repoFullName); + return repo?.installationId ?? 0; +} + export async function upsertRepoSyncState(env: Env, state: RepoSyncStateRecord): Promise { const db = getDb(env.DB); await db diff --git a/src/db/schema.ts b/src/db/schema.ts index 8630cd327f..382e0a2885 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -22,7 +22,7 @@ export const repositories = sqliteTable("repositories", { fullName: text("full_name").primaryKey(), owner: text("owner").notNull(), name: text("name").notNull(), - installationId: integer("installation_id"), + installationId: integer("installation_id").notNull().default(0), isInstalled: integer("is_installed", { mode: "boolean" }).notNull().default(false), isRegistered: integer("is_registered", { mode: "boolean" }).notNull().default(false), isPrivate: integer("is_private", { mode: "boolean" }).notNull().default(false), @@ -38,8 +38,11 @@ export const repositories = sqliteTable("repositories", { updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), }); -export const repositorySettings = sqliteTable("repository_settings", { - repoFullName: text("repo_full_name").primaryKey(), +export const repositorySettings = sqliteTable( + "repository_settings", + { + repoFullName: text("repo_full_name").notNull(), + installationId: integer("installation_id").notNull().default(0), commentMode: text("comment_mode").notNull().default("detected_contributors_only"), publicAudienceMode: text("public_audience_mode").notNull().default("oss_maintainer"), publicSignalLevel: text("public_signal_level").notNull().default("standard"), @@ -77,13 +80,21 @@ export const repositorySettings = sqliteTable("repository_settings", { agentDryRun: integer("agent_dry_run", { mode: "boolean" }).notNull().default(false), createdAt: text("created_at").notNull().$defaultFn(() => nowIso()), updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), -}); + }, + (table) => ({ + repoInstallation: uniqueIndex("repository_settings_repo_installation_unique").on(table.repoFullName, table.installationId), + repoUpdated: index("repository_settings_repo_updated_idx").on(table.repoFullName, table.updatedAt), + }), +); // Maintainer BYOK provider keys (Anthropic/OpenAI), encrypted at rest with AES-256-GCM. Isolated in its // own table so the ciphertext is NEVER serialized by the repository-settings GET surface. The plaintext // key is never stored; `last4` is a display-only hint derived from the plaintext at write time. -export const repositoryAiKeys = sqliteTable("repository_ai_keys", { - repoFullName: text("repo_full_name").primaryKey(), +export const repositoryAiKeys = sqliteTable( + "repository_ai_keys", + { + repoFullName: text("repo_full_name").notNull(), + installationId: integer("installation_id").notNull().default(0), provider: text("provider").notNull(), ciphertext: text("ciphertext").notNull(), iv: text("iv").notNull(), @@ -97,7 +108,12 @@ export const repositoryAiKeys = sqliteTable("repository_ai_keys", { createdBy: text("created_by"), createdAt: text("created_at").notNull().$defaultFn(() => nowIso()), updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), -}); + }, + (table) => ({ + repoInstallation: uniqueIndex("repository_ai_keys_repo_installation_unique").on(table.repoFullName, table.installationId), + repoUpdated: index("repository_ai_keys_repo_updated_idx").on(table.repoFullName, table.updatedAt), + }), +); export const repoSyncState = sqliteTable("repo_sync_state", { repoFullName: text("repo_full_name").primaryKey(), diff --git a/src/env.d.ts b/src/env.d.ts index 57679923ee..32f1f637e4 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -46,6 +46,9 @@ declare global { GITTENSOR_UPSTREAM_REPO?: string; GITTENSOR_UPSTREAM_REF?: string; GITTENSOR_REGISTRY_URL: string; + /** #697: metagraphed base URL. When set, subnet/netuid integration claims in PRs/issues are validated + * (existence + interface health) and surfaced as ADVISORY gate evidence. Unset = feature dormant. */ + METAGRAPHED_API_URL?: string; GITHUB_PUBLIC_TOKEN?: string; /** #703: owner-gated global to apply upstream sigmoid time-decay in score previews. Default off. */ SCORING_TIME_DECAY_ENABLED?: string; diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 10c2fff959..ec02b6780e 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -160,6 +160,7 @@ import { isVisualPath } from "../review/visual/paths"; import { buildCapture, type CaptureRoute } from "../review/visual/capture"; import type { CheckFailureDetail, MergeReadiness } from "../review/unified-comment"; import { buildIssueSlopAssessment, buildSlopAssessment, type SlopBand } from "../signals/slop"; +import { assessSubnetClaimFindings } from "../services/metagraphed"; import { runGittensoryAiSlopAdvisory } from "../services/ai-slop"; import { decidePublicSurface } from "../signals/settings-preview"; import { buildFocusManifestGuidance } from "../signals/focus-manifest"; @@ -1306,6 +1307,9 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str otherOpenPullRequests, requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings), }); + // #697: validate any subnet/netuid integration claim against metagraphed and attach the verdict as + // advisory gate evidence. No-op (and no network call) unless METAGRAPHED_API_URL is configured. + advisory.findings.push(...(await assessSubnetClaimFindings(env, { title: pr.title, body: pr.body }))); await persistAdvisory(env, advisory); if (installationId && shouldProcessPullRequestPublicSurface(payload.action)) { if (shouldCollectSlopEvidence(settings) || settings.manifestPolicyGateMode !== "off" || isAgentConfigured(settings.autonomy)) { @@ -1378,6 +1382,9 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str if (issueSettings.slopGateMode !== "off") { advisory.findings.push(...buildIssueSlopAssessment({ title: issue.title, body: issue.body }).findings); } + // #697: subnet/netuid claim validation also applies to issues ("integrates subnet X"). Advisory-only; + // dormant unless METAGRAPHED_API_URL is set. + advisory.findings.push(...(await assessSubnetClaimFindings(env, { title: issue.title, body: issue.body }))); await persistAdvisory(env, advisory); // #699 path B: a newly opened grabbable, high-multiplier issue notifies the miners watching this repo // (fanned out through the same #535 pipeline below). diff --git a/src/review/guardrail-config.ts b/src/review/guardrail-config.ts index 8d60693fd7..1991e14a68 100644 --- a/src/review/guardrail-config.ts +++ b/src/review/guardrail-config.ts @@ -32,12 +32,21 @@ function asNonEmptyStringArray(value: unknown): string[] | null { * DEFAULT_CRUCIAL_GUARDRAIL_GLOBS so a freshly-installed repo can still operate; but a THROWN read (KV outage) * fails CLOSED to FAIL_CLOSED_GUARDRAIL_GLOBS so a config fault can never open the gate during a flood. */ -export async function loadHardGuardrailGlobs(env: Env, repoFullName: string): Promise { +export async function loadHardGuardrailGlobs(env: Env, repoFullName: string, installationId?: number | null | undefined): Promise { const slug = repoFullName.includes("/") ? repoFullName.slice(repoFullName.indexOf("/") + 1) : repoFullName; if (!env.REVIEW_CONFIG) return DEFAULT_CRUCIAL_GUARDRAIL_GLOBS; try { - const config = (await env.REVIEW_CONFIG.get(slug, "json")) as { hardGuardrailGlobs?: JsonValue } | null; - return asNonEmptyStringArray(config?.hardGuardrailGlobs) ?? DEFAULT_CRUCIAL_GUARDRAIL_GLOBS; + const candidateKeys = [ + ...(installationId !== undefined && installationId !== null ? [`installation:${installationId}:${repoFullName.toLowerCase()}`, `installation:${installationId}:${slug.toLowerCase()}`] : []), + repoFullName.toLowerCase(), + slug.toLowerCase(), + ]; + for (const key of candidateKeys) { + const config = (await env.REVIEW_CONFIG.get(key, "json")) as { hardGuardrailGlobs?: JsonValue } | null; + const globs = asNonEmptyStringArray(config?.hardGuardrailGlobs); + if (globs) return globs; + } + return DEFAULT_CRUCIAL_GUARDRAIL_GLOBS; } catch { return FAIL_CLOSED_GUARDRAIL_GLOBS; } diff --git a/src/services/metagraphed.ts b/src/services/metagraphed.ts new file mode 100644 index 0000000000..136680744d --- /dev/null +++ b/src/services/metagraphed.ts @@ -0,0 +1,91 @@ +// #697 (roadmap #525): the metagraphed consumer. Validates a claimed Bittensor subnet/netuid against +// metagraphed (netuid existence + interface health) and adapts the verdict into advisory gate findings. +// +// This mirrors gittensor/api.ts's fetch discipline (JSON accept header, hard timeout, never let a slow +// upstream hang the Worker). It is fail-open and ADVISORY: any error, timeout, or unexpected shape maps to +// `unavailable`, which produces NO finding — a metagraphed outage must never block or spam a contributor +// (#525: advisory-first, no auto-block). The feature is dormant until `METAGRAPHED_API_URL` is configured. + +import type { AdvisoryFinding } from "../types"; +import { assessSubnetClaims, type NetuidValidation } from "../signals/subnet-claim"; + +/** Hard cap on a single metagraphed request so a slow/half-open upstream can never stall the webhook. */ +export const METAGRAPHED_FETCH_TIMEOUT_MS = 10_000; + +/** Tolerant view of metagraphed's subnet response. Only `exists === false` and `healthy === false` are + * treated as negative signals; any other/missing shape is read as "exists, healthy" so an unrecognized + * 200 never yields a false-positive finding. `interfaceHealthy` / `interface.healthy` are accepted as + * aliases for `healthy` to match the netuid-existence/chain-binding shape from the reviewbot work. */ +export type MetagraphedSubnetResponse = { + netuid?: number; + exists?: boolean; + healthy?: boolean; + interfaceHealthy?: boolean; + interface?: { healthy?: boolean } | null; +}; + +/** Map a parsed metagraphed subnet response to a validation verdict. Exported for direct unit testing. */ +export function interpretSubnetResponse(netuid: number, data: MetagraphedSubnetResponse): NetuidValidation { + if (data.exists === false) { + return { netuid, status: "not_found", detail: `metagraphed reports subnet ${netuid} does not exist.` }; + } + const healthy = data.healthy ?? data.interfaceHealthy ?? data.interface?.healthy; + if (healthy === false) { + return { netuid, status: "exists_unhealthy", detail: `metagraphed reports subnet ${netuid} interface health did not pass.` }; + } + return { netuid, status: "exists_healthy", detail: `metagraphed confirms subnet ${netuid}.` }; +} + +export type MetagraphedClientDeps = { + /** metagraphed base URL (no trailing slash required). */ + readonly baseUrl: string; + /** Injected fetch for tests; defaults to global `fetch`. */ + readonly fetchImpl?: typeof fetch | undefined; + readonly timeoutMs?: number | undefined; +}; + +/** + * Validate one netuid against metagraphed. Never rejects: a 404 → `not_found`, any other non-2xx / network + * error / timeout / parse failure → `unavailable`, and a 2xx is interpreted by {@link interpretSubnetResponse}. + */ +export async function validateNetuid(netuid: number, deps: MetagraphedClientDeps): Promise { + const base = deps.baseUrl.replace(/\/+$/, ""); + const url = `${base}/subnets/${netuid}`; + const fetchImpl = deps.fetchImpl ?? fetch; + try { + const response = await fetchImpl(url, { + headers: { accept: "application/json", "user-agent": "gittensory/0.1" }, + signal: AbortSignal.timeout(deps.timeoutMs ?? METAGRAPHED_FETCH_TIMEOUT_MS), + }); + if (response.status === 404) { + return { netuid, status: "not_found", detail: `metagraphed reports subnet ${netuid} does not exist.` }; + } + if (!response.ok) { + return { netuid, status: "unavailable", detail: `metagraphed returned status ${response.status} for subnet ${netuid}.` }; + } + return interpretSubnetResponse(netuid, (await response.json()) as MetagraphedSubnetResponse); + } catch { + return { netuid, status: "unavailable", detail: `metagraphed could not be reached for subnet ${netuid}.` }; + } +} + +/** + * Top-level adapter used by the webhook pipeline: detect subnet claims in a contribution's title/body and + * return advisory findings sourced from metagraphed. Returns `[]` (and makes no network call) when + * `METAGRAPHED_API_URL` is unset, so the feature is fully opt-in and existing behavior is unchanged. + */ +export async function assessSubnetClaimFindings( + env: Pick, + input: { readonly title?: string | null | undefined; readonly body?: string | null | undefined }, + deps: { readonly fetchImpl?: typeof fetch | undefined; readonly timeoutMs?: number | undefined } = {}, +): Promise { + const baseUrl = env.METAGRAPHED_API_URL?.trim(); + if (!baseUrl) return []; + return assessSubnetClaims(input, (netuid) => + validateNetuid(netuid, { + baseUrl, + ...(deps.fetchImpl ? { fetchImpl: deps.fetchImpl } : {}), + ...(deps.timeoutMs ? { timeoutMs: deps.timeoutMs } : {}), + }), + ); +} diff --git a/src/signals/subnet-claim.ts b/src/signals/subnet-claim.ts new file mode 100644 index 0000000000..fc9a438ea1 --- /dev/null +++ b/src/signals/subnet-claim.ts @@ -0,0 +1,104 @@ +// #697 (roadmap #525): gittensory consumes metagraphed — validate subnet/netuid claims as gate evidence. +// +// PURE core of the subnet-claim gate signal. It (a) detects when a contribution's text claims to integrate +// a Bittensor subnet/netuid, and (b) turns a metagraphed validation verdict for that netuid into a +// public-safe, ADVISORY `AdvisoryFinding`. The actual metagraphed HTTP call lives in +// ../services/metagraphed.ts (injected here as `validate`), so this module stays deterministic and +// unit-testable without a network. +// +// Advisory-first (#525): findings are `warning` severity at most — they surface evidence, they never hard +// block. Public-safe (#542): wording uses only subnet/netuid/interface/metagraphed vocabulary and is run +// through `isPublicSafeText` in tests, so it carries no reward/score/identity language. + +import type { AdvisoryFinding } from "../types"; + +/** A subnet/netuid integration claim parsed from contribution text. */ +export type SubnetClaim = { readonly netuid: number; readonly raw: string }; + +/** Verdict for one claimed netuid, sourced from metagraphed. `unavailable` = could not validate (metagraphed + * unreachable/unexpected) — deliberately produces NO finding so a metagraphed outage is never noisy. */ +export type NetuidValidationStatus = "exists_healthy" | "exists_unhealthy" | "not_found" | "unavailable"; + +export type NetuidValidation = { + readonly netuid: number; + readonly status: NetuidValidationStatus; + readonly detail: string; +}; + +/** Validate a single netuid against metagraphed. Implemented by ../services/metagraphed.ts; injected so the + * pure layer can be tested with a fake. Must never reject — connectivity failures map to `unavailable`. */ +export type NetuidValidator = (netuid: number) => Promise; + +/** Highest netuid we treat as a plausible subnet claim. Keeps detection from matching years / PR numbers / + * large unrelated integers (Bittensor netuids are small and dense from 0). */ +export const MAX_RECOGNIZED_NETUID = 1023; + +// Matches "subnet 42", "subnet #42", "subnet-42", "subnets 42", "netuid 42", "netuid: 5", "net uid 5", +// "netuid=12", "sn74", "SN 74", "subnet number 7". The number is captured; a separator is optional but the +// number must follow within optional whitespace/separator so plain words ("subnetwork", "snapshot") miss. +const SUBNET_CLAIM_PATTERN = /\b(?:net\s?uid|subnets?|sn)\s*(?:number\s*)?[:#=-]?\s*(\d{1,5})\b/gi; + +/** + * Detect distinct subnet/netuid integration claims in free text (PR/issue title + body). Returns at most one + * claim per netuid (first mention wins), sorted ascending for deterministic output. Out-of-range numbers + * (> MAX_RECOGNIZED_NETUID) are ignored to avoid false positives on years/IDs. + */ +export function detectSubnetClaims(text: string | null | undefined): SubnetClaim[] { + if (!text) return []; + const byNetuid = new Map(); + for (const match of text.matchAll(SUBNET_CLAIM_PATTERN)) { + const netuid = Number(match[1]); + if (!Number.isInteger(netuid) || netuid < 0 || netuid > MAX_RECOGNIZED_NETUID) continue; + if (!byNetuid.has(netuid)) byNetuid.set(netuid, match[0].trim()); + } + return [...byNetuid.entries()].map(([netuid, raw]) => ({ netuid, raw })).sort((a, b) => a.netuid - b.netuid); +} + +/** + * Turn one metagraphed verdict into an advisory finding. Returns `null` when there is nothing to surface — + * the netuid exists and is healthy, or metagraphed could not be reached (`unavailable`). Only a missing + * (`not_found`) or unhealthy (`exists_unhealthy`) subnet produces a finding. + */ +export function buildSubnetClaimFinding(validation: NetuidValidation): AdvisoryFinding | null { + const { netuid } = validation; + if (validation.status === "not_found") { + return { + code: "subnet_claim_not_found", + severity: "warning", + title: `Claimed subnet ${netuid} was not found via metagraphed`, + detail: `This contribution references subnet/netuid ${netuid}, but metagraphed reports no such subnet on the network. ${validation.detail}`, + action: `Verify the netuid and correct or remove the subnet ${netuid} integration claim.`, + publicText: `metagraphed could not find subnet ${netuid}; verify the referenced netuid.`, + }; + } + if (validation.status === "exists_unhealthy") { + return { + code: "subnet_claim_unhealthy", + severity: "warning", + title: `Claimed subnet ${netuid} appears unhealthy via metagraphed`, + detail: `metagraphed reports subnet/netuid ${netuid} exists but its interface health check did not pass, so the integration claim could not be confirmed as healthy. ${validation.detail}`, + action: `Confirm the subnet ${netuid} interface is reachable, or note the integration as experimental.`, + publicText: `metagraphed reports subnet ${netuid} exists but its interface health check did not pass.`, + }; + } + return null; +} + +/** + * Assess all subnet/netuid claims in a contribution's title + body and return the advisory findings sourced + * from metagraphed. Validates each distinct claimed netuid via the injected validator. Deterministic and + * never throws (the validator must map failures to `unavailable`). Empty result = no claims, or all claimed + * subnets validated cleanly / could not be checked. + */ +export async function assessSubnetClaims( + input: { readonly title?: string | null | undefined; readonly body?: string | null | undefined }, + validate: NetuidValidator, +): Promise { + const claims = detectSubnetClaims(`${input.title ?? ""}\n${input.body ?? ""}`); + const findings: AdvisoryFinding[] = []; + for (const claim of claims) { + const finding = buildSubnetClaimFinding(await validate(claim.netuid)); + if (finding) findings.push(finding); + } + return findings; +} diff --git a/src/types.ts b/src/types.ts index 71b10d4edb..c133ae4a5f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -458,6 +458,7 @@ export type GatePolicyPack = "gittensor" | "oss-anti-slop"; export type RepositorySettings = { repoFullName: string; + installationId?: number | null | undefined; commentMode: "off" | "detected_contributors_only" | "all_prs"; publicAudienceMode: "oss_maintainer" | "gittensor_only"; publicSignalLevel: "minimal" | "standard"; diff --git a/test/unit/ai-key-byok.test.ts b/test/unit/ai-key-byok.test.ts index 3c0d2899e8..5d8a29911a 100644 --- a/test/unit/ai-key-byok.test.ts +++ b/test/unit/ai-key-byok.test.ts @@ -69,6 +69,23 @@ describe("repository BYOK key storage", () => { await expect(getDecryptedRepositoryAiKey(env, "acme/widgets")).resolves.toBeNull(); }); + it("isolates BYOK records by installation for the same repo slug", async () => { + const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); + await env.DB.prepare("insert into repositories (full_name, owner, name, installation_id, is_installed, is_registered, is_private, created_at, updated_at) values (?, ?, ?, ?, 1, 1, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)") + .bind("acme/widgets", "acme", "widgets", 11) + .run(); + await upsertRepositoryAiKey(env, { repoFullName: "acme/widgets", installationId: 11, provider: "anthropic", key: "sk-ant-tenant-a-1111", model: null }); + await upsertRepositoryAiKey(env, { repoFullName: "acme/widgets", installationId: 22, provider: "openai", key: "sk-openai-tenant-b-2222", model: null }); + + await env.DB.prepare("update repositories set installation_id = ? where full_name = ?").bind(11, "acme/widgets").run(); + await expect(getRepositoryAiKeyStatus(env, "acme/widgets")).resolves.toMatchObject({ configured: true, provider: "anthropic", last4: "1111" }); + await expect(getDecryptedRepositoryAiKey(env, "acme/widgets")).resolves.toMatchObject({ provider: "anthropic", key: "sk-ant-tenant-a-1111" }); + + await env.DB.prepare("update repositories set installation_id = ? where full_name = ?").bind(22, "acme/widgets").run(); + await expect(getRepositoryAiKeyStatus(env, "acme/widgets")).resolves.toMatchObject({ configured: true, provider: "openai", last4: "2222" }); + await expect(getDecryptedRepositoryAiKey(env, "acme/widgets")).resolves.toMatchObject({ provider: "openai", key: "sk-openai-tenant-b-2222" }); + }); + it("audits the key lifecycle (set → replace → delete) without ever recording key material", async () => { const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); await upsertRepositoryAiKey(env, { repoFullName: "acme/widgets", provider: "anthropic", key: "sk-ant-first-key-0000", createdBy: "alice" }); @@ -92,7 +109,7 @@ describe("repository BYOK key storage", () => { it("stores real ISO timestamps when created_at/updated_at are omitted (no literal default)", async () => { const env = createTestEnv({ TOKEN_ENCRYPTION_SECRET: SECRET }); const db = getDb(env.DB); - await db.insert(repositoryAiKeys).values({ repoFullName: "acme/widgets", provider: "anthropic", ciphertext: "ct", iv: "iv", last4: "7890" }); + await db.insert(repositoryAiKeys).values({ repoFullName: "acme/widgets", installationId: 0, provider: "anthropic", ciphertext: "ct", iv: "iv", last4: "7890" }); const [row] = await db.select().from(repositoryAiKeys).where(eq(repositoryAiKeys.repoFullName, "acme/widgets")).limit(1); expect(row?.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); expect(row?.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts index f28d07c59f..424355c8ad 100644 --- a/test/unit/data-spine.test.ts +++ b/test/unit/data-spine.test.ts @@ -270,6 +270,13 @@ describe("data spine repositories", () => { expect(await getRepositorySettings(env, "owner/repo")).toMatchObject({ gatePack: "gittensor", linkedIssueGateMode: "block" }); await upsertRepositorySettings(env, { repoFullName: "owner/defaultpack" }); expect((await getRepositorySettings(env, "owner/defaultpack")).gatePack).toBe("gittensor"); + // #1028 installation-scoped settings isolation: same repoFullName can carry independent hosted state. + await upsertRepositorySettings(env, { repoFullName: "owner/tenantrepo", installationId: 101, gatePack: "oss-anti-slop", linkedIssueGateMode: "advisory" }); + await upsertRepositorySettings(env, { repoFullName: "owner/tenantrepo", installationId: 202, gatePack: "gittensor", linkedIssueGateMode: "block" }); + await upsertRepositoryFromGitHub(env, { name: "tenantrepo", full_name: "owner/tenantrepo", owner: { login: "owner" } }, 101); + expect(await getRepositorySettings(env, "owner/tenantrepo")).toMatchObject({ installationId: 101, gatePack: "oss-anti-slop", linkedIssueGateMode: "advisory" }); + await env.DB.prepare("update repositories set installation_id = ? where full_name = ?").bind(202, "owner/tenantrepo").run(); + expect(await getRepositorySettings(env, "owner/tenantrepo")).toMatchObject({ installationId: 202, gatePack: "gittensor", linkedIssueGateMode: "block" }); // slop gate (#530/#532) round-trips and defaults to off. await upsertRepositorySettings(env, { repoFullName: "owner/sloprepo", slopGateMode: "block", slopGateMinScore: 55, slopAiAdvisory: true }); const slopSettings = await getRepositorySettings(env, "owner/sloprepo"); diff --git a/test/unit/guardrail-config.test.ts b/test/unit/guardrail-config.test.ts index ee8902fd11..eb6b7ad407 100644 --- a/test/unit/guardrail-config.test.ts +++ b/test/unit/guardrail-config.test.ts @@ -10,11 +10,24 @@ describe("loadHardGuardrailGlobs", () => { expect(await loadHardGuardrailGlobs({} as Env, "JSONbored/gittensory")).toEqual(DEFAULT_CRUCIAL_GUARDRAIL_GLOBS); }); - it("reads globs from KV keyed by the repo slug (owner stripped)", async () => { + it("uses the legacy full-name key before the slug fallback when no installation-scoped key exists", async () => { const get = vi.fn().mockResolvedValue({ hardGuardrailGlobs: ["src/scoring/**", "scripts/**"] }); const globs = await loadHardGuardrailGlobs(envWith(get), "JSONbored/gittensory"); expect(globs).toEqual(["src/scoring/**", "scripts/**"]); - expect(get).toHaveBeenCalledWith("gittensory", "json"); + expect(get).toHaveBeenCalledTimes(1); + expect(get).toHaveBeenNthCalledWith(1, "jsonbored/gittensory", "json"); + }); + + it("prefers the installation-scoped key to avoid cross-tenant bleed on same-slug repos", async () => { + const get = vi.fn(async (key: string) => { + if (key === "installation:42:gittensory") return { hardGuardrailGlobs: ["tenant-a/**"] }; + if (key === "gittensory") return { hardGuardrailGlobs: ["legacy-shared/**"] }; + return null; + }); + const globs = await loadHardGuardrailGlobs(envWith(get), "JSONbored/gittensory", 42); + expect(globs).toEqual(["tenant-a/**"]); + expect(get).toHaveBeenNthCalledWith(1, "installation:42:jsonbored/gittensory", "json"); + expect(get).toHaveBeenNthCalledWith(2, "installation:42:gittensory", "json"); }); it("falls back to the default when the field is absent, null, or empty", async () => { diff --git a/test/unit/metagraphed.test.ts b/test/unit/metagraphed.test.ts new file mode 100644 index 0000000000..462223a347 --- /dev/null +++ b/test/unit/metagraphed.test.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + assessSubnetClaimFindings, + interpretSubnetResponse, + METAGRAPHED_FETCH_TIMEOUT_MS, + validateNetuid, + type MetagraphedSubnetResponse, +} from "../../src/services/metagraphed"; + +/** Build a minimal fetch stub returning the given status + JSON body. */ +function fetchReturning(status: number, body: unknown): typeof fetch { + return vi.fn(async () => ({ status, ok: status >= 200 && status < 300, json: async () => body }) as unknown as Response) as unknown as typeof fetch; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("interpretSubnetResponse", () => { + it("maps explicit non-existence to not_found", () => { + expect(interpretSubnetResponse(9, { exists: false }).status).toBe("not_found"); + }); + it("maps an unhealthy interface (any alias) to exists_unhealthy", () => { + expect(interpretSubnetResponse(9, { healthy: false }).status).toBe("exists_unhealthy"); + expect(interpretSubnetResponse(9, { interfaceHealthy: false }).status).toBe("exists_unhealthy"); + expect(interpretSubnetResponse(9, { interface: { healthy: false } }).status).toBe("exists_unhealthy"); + }); + it("treats present/healthy/unknown shapes as exists_healthy", () => { + expect(interpretSubnetResponse(9, { exists: true, healthy: true }).status).toBe("exists_healthy"); + expect(interpretSubnetResponse(9, { netuid: 9 }).status).toBe("exists_healthy"); + expect(interpretSubnetResponse(9, {} as MetagraphedSubnetResponse).status).toBe("exists_healthy"); + expect(interpretSubnetResponse(9, { interface: null }).status).toBe("exists_healthy"); + }); +}); + +describe("validateNetuid", () => { + it("maps HTTP 404 to not_found and strips a trailing slash from the base URL", async () => { + const fetchImpl = fetchReturning(404, {}); + const result = await validateNetuid(42, { baseUrl: "https://meta.example/", fetchImpl }); + expect(result.status).toBe("not_found"); + expect(fetchImpl).toHaveBeenCalledWith("https://meta.example/subnets/42", expect.objectContaining({ headers: expect.any(Object) })); + }); + + it("maps other non-2xx responses to unavailable", async () => { + const result = await validateNetuid(42, { baseUrl: "https://meta.example", fetchImpl: fetchReturning(503, {}) }); + expect(result.status).toBe("unavailable"); + }); + + it("interprets a 2xx body (not_found / unhealthy / healthy)", async () => { + expect((await validateNetuid(1, { baseUrl: "https://m", fetchImpl: fetchReturning(200, { exists: false }) })).status).toBe("not_found"); + expect((await validateNetuid(1, { baseUrl: "https://m", fetchImpl: fetchReturning(200, { healthy: false }) })).status).toBe("exists_unhealthy"); + expect((await validateNetuid(1, { baseUrl: "https://m", fetchImpl: fetchReturning(200, { healthy: true }) })).status).toBe("exists_healthy"); + }); + + it("never rejects — network/parse errors map to unavailable", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch; + const result = await validateNetuid(42, { baseUrl: "https://meta.example", fetchImpl }); + expect(result.status).toBe("unavailable"); + expect(result.detail).toContain("42"); + }); + + it("uses the global fetch and default timeout when none are injected", async () => { + const globalFetch = fetchReturning(200, { healthy: true }); + vi.stubGlobal("fetch", globalFetch); + const result = await validateNetuid(74, { baseUrl: "https://meta.example", timeoutMs: 1234 }); + expect(result.status).toBe("exists_healthy"); + expect(globalFetch).toHaveBeenCalledTimes(1); + expect(METAGRAPHED_FETCH_TIMEOUT_MS).toBe(10_000); + }); +}); + +describe("assessSubnetClaimFindings", () => { + it("is dormant (no findings, no fetch) when METAGRAPHED_API_URL is unset or blank", async () => { + const fetchImpl = fetchReturning(404, {}); + expect(await assessSubnetClaimFindings({}, { title: "integrates subnet 42" }, { fetchImpl })).toEqual([]); + expect(await assessSubnetClaimFindings({ METAGRAPHED_API_URL: " " }, { title: "integrates subnet 42" }, { fetchImpl })).toEqual([]); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("surfaces an advisory finding when a claimed subnet is not found (acceptance criterion)", async () => { + const findings = await assessSubnetClaimFindings( + { METAGRAPHED_API_URL: "https://meta.example" }, + { title: "feat: integrates subnet 999", body: "wires the new subnet" }, + { fetchImpl: fetchReturning(404, {}), timeoutMs: 2000 }, + ); + expect(findings).toHaveLength(1); + expect(findings[0]?.code).toBe("subnet_claim_not_found"); + expect(findings[0]?.detail).toContain("metagraphed"); + }); + + it("falls back to global fetch when no fetchImpl is provided", async () => { + const globalFetch = fetchReturning(200, { healthy: false }); + vi.stubGlobal("fetch", globalFetch); + const findings = await assessSubnetClaimFindings({ METAGRAPHED_API_URL: "https://meta.example" }, { body: "uses subnet 5" }); + expect(findings.map((f) => f.code)).toEqual(["subnet_claim_unhealthy"]); + expect(globalFetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unit/subnet-claim.test.ts b/test/unit/subnet-claim.test.ts new file mode 100644 index 0000000000..46971e5bcf --- /dev/null +++ b/test/unit/subnet-claim.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from "vitest"; +import { + assessSubnetClaims, + buildSubnetClaimFinding, + detectSubnetClaims, + MAX_RECOGNIZED_NETUID, + type NetuidValidation, +} from "../../src/signals/subnet-claim"; +import { isPublicSafeText } from "../../src/signals/redaction"; + +function assertPublicSafe(finding: { title: string; detail: string; action?: string; publicText?: string }) { + for (const text of [finding.title, finding.detail, finding.action ?? "", finding.publicText ?? ""]) { + expect(isPublicSafeText(text)).toBe(true); + } +} + +describe("detectSubnetClaims", () => { + it("returns nothing for empty/blank input", () => { + expect(detectSubnetClaims(null)).toEqual([]); + expect(detectSubnetClaims(undefined)).toEqual([]); + expect(detectSubnetClaims("")).toEqual([]); + expect(detectSubnetClaims("just a normal description with no claims")).toEqual([]); + }); + + it("detects the common subnet/netuid phrasings", () => { + expect(detectSubnetClaims("This integrates subnet 42 cleanly")).toEqual([{ netuid: 42, raw: "subnet 42" }]); + expect(detectSubnetClaims("targets netuid 5")[0]?.netuid).toBe(5); + expect(detectSubnetClaims("net uid 9")[0]?.netuid).toBe(9); + expect(detectSubnetClaims("netuid: 12")[0]?.netuid).toBe(12); + expect(detectSubnetClaims("netuid=7")[0]?.netuid).toBe(7); + expect(detectSubnetClaims("subnet #3")[0]?.netuid).toBe(3); + expect(detectSubnetClaims("subnet-8")[0]?.netuid).toBe(8); + expect(detectSubnetClaims("supports subnets 2")[0]?.netuid).toBe(2); + expect(detectSubnetClaims("built for SN74")[0]?.netuid).toBe(74); + expect(detectSubnetClaims("sn 11")[0]?.netuid).toBe(11); + expect(detectSubnetClaims("subnet number 7")[0]?.netuid).toBe(7); + }); + + it("deduplicates by netuid and sorts ascending", () => { + expect(detectSubnetClaims("subnet 42 and later netuid 42, plus subnet 5")).toEqual([ + { netuid: 5, raw: "subnet 5" }, + { netuid: 42, raw: "subnet 42" }, + ]); + }); + + it("ignores out-of-range numbers and non-claim words", () => { + expect(detectSubnetClaims("released in subnet 2024")).toEqual([]); // year-like, > MAX + expect(detectSubnetClaims(`subnet ${MAX_RECOGNIZED_NETUID + 1}`)).toEqual([]); + expect(detectSubnetClaims(`subnet ${MAX_RECOGNIZED_NETUID}`)).toEqual([{ netuid: MAX_RECOGNIZED_NETUID, raw: `subnet ${MAX_RECOGNIZED_NETUID}` }]); + expect(detectSubnetClaims("refactored the subnetwork module")).toEqual([]); + expect(detectSubnetClaims("took a snapshot at step 5")).toEqual([]); + expect(detectSubnetClaims("netuid zero")).toEqual([]); // no digit + }); + + it("accepts the root subnet (netuid 0)", () => { + expect(detectSubnetClaims("binds netuid 0")).toEqual([{ netuid: 0, raw: "netuid 0" }]); + }); +}); + +describe("buildSubnetClaimFinding", () => { + it("surfaces a public-safe warning for a non-existent subnet", () => { + const finding = buildSubnetClaimFinding({ netuid: 999, status: "not_found", detail: "metagraphed reports subnet 999 does not exist." }); + expect(finding).not.toBeNull(); + expect(finding!.code).toBe("subnet_claim_not_found"); + expect(finding!.severity).toBe("warning"); + expect(finding!.title).toContain("999"); + expect(finding!.detail).toContain("metagraphed"); + assertPublicSafe(finding!); + }); + + it("surfaces a public-safe warning for an unhealthy subnet", () => { + const finding = buildSubnetClaimFinding({ netuid: 12, status: "exists_unhealthy", detail: "metagraphed reports subnet 12 interface health did not pass." }); + expect(finding!.code).toBe("subnet_claim_unhealthy"); + expect(finding!.severity).toBe("warning"); + assertPublicSafe(finding!); + }); + + it("produces no finding for healthy or unavailable verdicts", () => { + expect(buildSubnetClaimFinding({ netuid: 7, status: "exists_healthy", detail: "ok" })).toBeNull(); + expect(buildSubnetClaimFinding({ netuid: 7, status: "unavailable", detail: "down" })).toBeNull(); + }); +}); + +describe("assessSubnetClaims", () => { + const validatorFor = (statuses: Record) => + vi.fn(async (netuid: number): Promise => ({ netuid, status: statuses[netuid] ?? "exists_healthy", detail: `verdict for ${netuid}` })); + + it("returns no findings when there are no claims (and never calls the validator)", async () => { + const validate = validatorFor({}); + expect(await assessSubnetClaims({ title: "plain title", body: "plain body" }, validate)).toEqual([]); + expect(validate).not.toHaveBeenCalled(); + }); + + it("validates each distinct claimed netuid across title + body and collects only actionable findings", async () => { + const validate = validatorFor({ 42: "not_found", 7: "exists_healthy", 9: "exists_unhealthy" }); + const findings = await assessSubnetClaims({ title: "integrates subnet 42", body: "also subnet 7 and subnet 9" }, validate); + expect(validate).toHaveBeenCalledTimes(3); // 7, 9, 42 distinct + expect(findings.map((f) => f.code)).toEqual(["subnet_claim_unhealthy", "subnet_claim_not_found"]); // netuid 7 healthy → omitted, sorted 9 then 42 + }); + + it("tolerates a missing title (body only)", async () => { + const validate = validatorFor({ 5: "not_found" }); + const findings = await assessSubnetClaims({ body: "needs subnet 5" }, validate); + expect(findings).toHaveLength(1); + expect(findings[0]?.code).toBe("subnet_claim_not_found"); + }); + + it("tolerates a missing body (title only)", async () => { + const validate = validatorFor({ 8: "not_found" }); + const findings = await assessSubnetClaims({ title: "integrates subnet 8" }, validate); + expect(findings).toHaveLength(1); + expect(findings[0]?.code).toBe("subnet_claim_not_found"); + }); +});