diff --git a/.agents/skills/validate-sas-release/SKILL.md b/.agents/skills/validate-sas-release/SKILL.md index fac01170..4f8fd403 100644 --- a/.agents/skills/validate-sas-release/SKILL.md +++ b/.agents/skills/validate-sas-release/SKILL.md @@ -3,10 +3,10 @@ name: validate-sas-release description: Validate Super Agent Skill changes before publication, using the existing local checks without Lovable generation credits. --- -Run `npm ci` when locked dependencies changed. Run `npm run check:project` from the repository root. This batches tests, types, dependency audit, build, lint regression checking, content validation and both generated-artifact checks. It writes actual exit codes and logs to `artifacts/validation/`; failures block release. +Run `npm ci` when locked dependencies changed. Run `npm run check:project` from the repository root. This batches tests, types, dependency audit, build, strict lint, content validation and both generated-artifact checks. It writes actual exit codes and logs to `artifacts/validation/`; failures block release. Use `npm run check:project -- --security` with the existing Supabase publishable environment to include RLS and anonymous-access checks. Never expose credentials or weaken allowlists to pass. With a local dev server running, add `--ui` to test signup at mobile and desktop sizes with intercepted auth; review its screenshots personally. -`npm run lint:strict` retains all ESLint errors. The normal lint command permits only the recorded pre-existing explicit-any debt per file; it blocks increases and all other errors. Never increase that baseline to make a change pass. Report outstanding debt explicitly. +`npm run lint` and `npm run lint:strict` both block every ESLint error, including every explicit `any`. No baseline exceptions are permitted. Validate the SDK with `npm run check:types:sdk` as well. Use generated database contracts and validated JSON boundaries; never replace removed types with unchecked assertions or inferred `any`. Check version-specific trust evidence, query-preserving auth redirects and MCP outcome semantics when those flows change. No model evaluations, signup creation, agent requests, provisioning, paid operations or production writes are part of this batch. Inspect and validate any additive SQL separately. Publish only after checks pass and publication is user-authorized; verify that Lovable's commit matches the validated code before using direct deployment. Preserve approval, signature and evidence gates. diff --git a/docs/testing/ACTIVATION-RELEASE.md b/docs/testing/ACTIVATION-RELEASE.md index 5b5b868e..1eff23d3 100644 --- a/docs/testing/ACTIVATION-RELEASE.md +++ b/docs/testing/ACTIVATION-RELEASE.md @@ -10,7 +10,7 @@ MCP v2 emits attempted/succeeded/failed/unknown outcomes. HTTP errors, JSON-RPC `npm run check:project` batches the existing tests, TypeScript, build, ESLint regression gate, content schema validation and generated mirrors/plugin checks. Add `--security` with existing publishable Supabase environment variables for the RLS/public-access probes; add `--ui` with a running local dev server for intercepted-auth desktop/mobile checks. Receipts live in ignored `artifacts/validation/`. Empty/incomplete batches fail. See `.agents/skills/validate-sas-release/SKILL.md`. -The old lint had 11,965 errors: formatting dominated, with 612 existing explicit-any uses. Formatting and other blocking rules were repaired. Those 612 type-debt instances are **not fixed**: `lint` reports them against the frozen original per-file counts in `security/lint-legacy-any.json`, blocks increases and blocks every other error. `lint:strict` retains the original ESLint command and still fails on this debt. Do not increase the baseline. React development/export warnings remain advisory. This adds a practical regression gate to CI, which previously ran only tests; it is not a claim that all typing debt is gone. +The earlier release recorded 612 explicit-any violations. The follow-up removes that allowance: both lint commands now block every ESLint error with zero explicit-any exceptions. The app and SDK have independent TypeScript checks. JSON boundaries validate agent builds, email queue messages and MCP envelopes, while database calls use the generated schema and optional migration contracts. React development/export warnings remain advisory. ## Prepublication evidence diff --git a/docs/testing/TYPE-SAFETY-RELEASE.md b/docs/testing/TYPE-SAFETY-RELEASE.md new file mode 100644 index 00000000..44f5a1d1 --- /dev/null +++ b/docs/testing/TYPE-SAFETY-RELEASE.md @@ -0,0 +1,29 @@ +# Type safety release validation + +The 612 explicit `any` violations recorded by the prior release have been removed. `scripts/lint.mjs` now blocks every ESLint error and writes an aggregate receipt. The per-file allowance file was deleted; ESLint rules remain unchanged. TypeScript now enables `noImplicitAny` as well as `strict`. The generated TanStack router file retains its existing generator-owned exclusions and is unchanged. + +Database clients use the generated database contract. `optional-schema.ts` describes the optional feature tables from the committed migrations named in that file; these types do not provision those tables. Read-only inspection through the native Lovable database integration confirmed the corrected live column names: `packages.author_id`, `package_upload_jobs.finished_at`, `mcp_oauth_tokens.token_hash`, and `skill_executions.version` / `error_kind` / required `package_id`. + +The exposed contract mismatches were corrected, including package search-vector serialization, version tags coming from the package, comparison runs from `package_metrics_daily`, and tip/fork ownership. Telemetry now uses one authoritative, rate-limited `report_skill_execution` RPC instead of an invalid direct insert followed by a second write. + +JSON boundaries validate persisted agent content, email queue messages, auth email fields, and MCP envelopes. Reauthentication requires its token without requiring an unused confirmation URL. Stripe keeps the existing gateway API version; a mocked transport test verifies its version and authentication headers without making a network call. Signature tests cover invalid signatures, stale or nonnumeric timestamps, and malformed envelopes. + +Run the reusable batch from the repository root: + +```sh +npm run check:project -- --ui --security +``` + +The security checks use the existing Supabase publishable environment. The UI check requires a local dev server on port 8080 and intercepts auth requests. No real signup, email, charge, model generation, or Lovable agent request is part of validation. + +Actual local evidence: + +- 198 tests pass, including eight new boundary/regression tests. +- Application and SDK TypeScript checks pass. +- Strict ESLint reports zero errors and zero explicit-any violations across 636 files; existing development warnings remain advisory. +- Production build and dependency audit pass. +- Content, skills mirror, and Agent Plugin artifact checks pass. +- RLS and anonymous-access probes pass across 85 public tables, preserving existing allowlists. +- Signup checks pass at 390px and 1440px; screenshots were visually reviewed. + +The batch writes logs, exact exit codes and aggregate results to `artifacts/validation/`. Jev's advisory review is retained there separately; deterministic checks and code inspection decide release readiness. Publication must use the validated Git commit after CI passes and source synchronization is confirmed. diff --git a/package.json b/package.json index 8779ba20..b11e2003 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "check:project": "node scripts/check-project.mjs", "test:ui": "node scripts/check-signup-ui.mjs", "check:types": "tsc --noEmit", - "check:dependencies": "npm audit --audit-level=high" + "check:dependencies": "npm audit --audit-level=high", + "check:types:sdk": "tsc -p packages/sdk-ts/tsconfig.json --noEmit" }, "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", diff --git a/packages/sdk-ts/src/index.ts b/packages/sdk-ts/src/index.ts index 652ae243..90b70f39 100644 --- a/packages/sdk-ts/src/index.ts +++ b/packages/sdk-ts/src/index.ts @@ -1,3 +1,4 @@ +import { parseEnvelope, type RpcEnvelope } from "./protocol"; /** * @superagentskill/sdk — typed client for the Super Agent Skill MCP server. * @@ -141,7 +142,10 @@ export class SuperAgentSkill { // ------------------ Internals ----------------------------------------- - private async rawRequest(body: unknown, init: { signal?: AbortSignal }): Promise { + private async rawRequest( + body: unknown, + init: { signal?: AbortSignal }, + ): Promise { let attempt = 0; let lastErr: unknown = null; while (attempt <= this.maxRetries) { @@ -262,20 +266,15 @@ function parseRetryAfter(res: Response): number { return 1; } -async function safeJson(res: Response): Promise { +async function safeJson(res: Response): Promise { try { - return await res.json(); + return parseEnvelope(await res.text()); } catch { return null; } } - -function tryParse(text: string): any { - try { - return JSON.parse(text); - } catch { - return null; - } +function tryParse(text: string): RpcEnvelope | null { + return parseEnvelope(text); } /** diff --git a/packages/sdk-ts/src/protocol.ts b/packages/sdk-ts/src/protocol.ts new file mode 100644 index 00000000..6b112561 --- /dev/null +++ b/packages/sdk-ts/src/protocol.ts @@ -0,0 +1,35 @@ +export interface RpcEnvelope { + result?: Record & { content?: Array<{ type: string; text?: string }> }; + error?: { message?: string; code?: number; data?: unknown }; +} +const record = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); +export function parseEnvelope(text: string): RpcEnvelope | null { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return null; + } + if (!record(value)) return null; + const out: RpcEnvelope = {}; + if (record(value.error)) { + out.error = { + message: typeof value.error.message === "string" ? value.error.message : undefined, + code: typeof value.error.code === "number" ? value.error.code : undefined, + data: value.error.data, + }; + } + if (record(value.result)) { + out.result = { ...value.result }; + if (Array.isArray(value.result.content)) + out.result.content = value.result.content + .filter(record) + .filter((item) => typeof item.type === "string") + .map((item) => ({ + type: String(item.type), + text: typeof item.text === "string" ? item.text : undefined, + })); + } + return out; +} diff --git a/packages/sdk-ts/src/types.ts b/packages/sdk-ts/src/types.ts index ae95ad71..d36a9def 100644 --- a/packages/sdk-ts/src/types.ts +++ b/packages/sdk-ts/src/types.ts @@ -145,11 +145,23 @@ export class MCPError extends Error { export class MCPUnauthorizedError extends MCPError { public readonly authorizationUrl?: string; public readonly tokensUrl?: string; - constructor(message: string, data?: any) { + constructor(message: string, data?: unknown) { super(message, { code: -32001, status: 401, data }); this.name = "MCPUnauthorizedError"; - this.authorizationUrl = data?.authorization_url; - this.tokensUrl = data?.tokens_url; + this.authorizationUrl = + data && + typeof data === "object" && + "authorization_url" in data && + typeof data.authorization_url === "string" + ? data.authorization_url + : undefined; + this.tokensUrl = + data && + typeof data === "object" && + "tokens_url" in data && + typeof data.tokens_url === "string" + ? data.tokens_url + : undefined; } } diff --git a/scripts/check-project.mjs b/scripts/check-project.mjs index 6455320c..304af599 100644 --- a/scripts/check-project.mjs +++ b/scripts/check-project.mjs @@ -4,6 +4,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; const checks = [ "test:all", "check:types", + "check:types:sdk", "check:dependencies", "build", "lint", diff --git a/scripts/lint.mjs b/scripts/lint.mjs index b681e042..bcdbb782 100644 --- a/scripts/lint.mjs +++ b/scripts/lint.mjs @@ -1,36 +1,32 @@ import { ESLint } from "eslint"; -import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { writeFileSync, mkdirSync } from "node:fs"; import { relative } from "node:path"; -const baseline = JSON.parse( - readFileSync(new URL("../security/lint-legacy-any.json", import.meta.url), "utf8"), -); const eslint = new ESLint(); const results = await eslint.lintFiles(["."]); if (!results.length) throw new Error("No files linted; refusing empty success"); let blocking = 0; -let debt = 0; +let explicitAny = 0; const report = []; for (const file of results) { - const path = relative(process.cwd(), file.filePath); - const legacy = file.messages.filter((m) => m.ruleId === "@typescript-eslint/no-explicit-any"); - const allowance = baseline[path] ?? 0; - debt += legacy.length; - const other = file.messages.filter( - (m) => m.severity === 2 && m.ruleId !== "@typescript-eslint/no-explicit-any", - ); - const excess = Math.max(0, legacy.length - allowance); - blocking += other.length + excess; - if (other.length || excess) console.error(path, other, { newExplicitAny: excess }); - if (legacy.length || other.length || excess) - report.push({ path, legacyAny: legacy.length, allowance, blocking: other.length + excess }); + const errors = file.messages.filter((m) => m.severity === 2); + const anyCount = file.messages.filter( + (m) => m.ruleId === "@typescript-eslint/no-explicit-any", + ).length; + blocking += errors.length; + explicitAny += anyCount; + if (errors.length) { + const path = relative(process.cwd(), file.filePath); + console.error(path, errors); + report.push({ path, explicitAny: anyCount, errors }); + } } mkdirSync("artifacts/validation", { recursive: true }); writeFileSync( "artifacts/validation/lint.json", - JSON.stringify({ files: results.length, blocking, legacyAny: debt, report }, null, 2), + JSON.stringify({ files: results.length, blocking, explicitAny, report }, null, 2), ); console.log( - `Linted ${results.length} files: ${blocking} blocking errors; ${debt} pre-existing explicit-any uses remain (visible debt, not fixed).`, + `Linted ${results.length} files: ${blocking} blocking errors; ${explicitAny} explicit-any violations.`, ); process.exitCode = blocking ? 1 : 0; diff --git a/security/lint-legacy-any.json b/security/lint-legacy-any.json deleted file mode 100644 index 13b7929b..00000000 --- a/security/lint-legacy-any.json +++ /dev/null @@ -1,178 +0,0 @@ -{ - "packages/sdk-ts/src/index.ts": 3, - "packages/sdk-ts/src/types.ts": 1, - "src/components/StripeEmbeddedCheckout.tsx": 1, - "src/components/agents/GuidedAgentEditor.tsx": 1, - "src/components/cloud-skills/ExportBundle.tsx": 1, - "src/components/cloud-skills/SyncHistory.tsx": 4, - "src/components/crm/EffectivenessPanel.tsx": 5, - "src/components/lineage/LineageCard.tsx": 1, - "src/components/marketplace/BuyButton.tsx": 1, - "src/components/site/McpTester.tsx": 19, - "src/lib/account/connection-status.functions.ts": 2, - "src/lib/account/packages.functions.ts": 6, - "src/lib/account/tokens-validate.functions.ts": 1, - "src/lib/account/tokens.functions.ts": 7, - "src/lib/account/usage.functions.ts": 2, - "src/lib/admin/accounts.functions.ts": 16, - "src/lib/admin/agent-kpis.functions.ts": 1, - "src/lib/admin/author.server.ts": 5, - "src/lib/admin/customers.functions.ts": 15, - "src/lib/admin/funnel.functions.ts": 6, - "src/lib/admin/imports.functions.ts": 12, - "src/lib/admin/meta-ads-pack.functions.ts": 10, - "src/lib/admin/middleware.ts": 2, - "src/lib/admin/packages.functions.ts": 6, - "src/lib/admin/payments-health.functions.ts": 3, - "src/lib/admin/plans.functions.ts": 5, - "src/lib/admin/requests.functions.ts": 11, - "src/lib/admin/review.functions.ts": 5, - "src/lib/adversarial/calibrate.server.ts": 3, - "src/lib/agents/agents.functions.ts": 1, - "src/lib/agents/agents.shared.ts": 1, - "src/lib/agents/factory-adapt.ts": 1, - "src/lib/agents/factory-runner.server.ts": 3, - "src/lib/agents/factory.functions.ts": 13, - "src/lib/agents/factory.server.ts": 6, - "src/lib/auth/bearer.server.ts": 1, - "src/lib/auth/paid.server.ts": 1, - "src/lib/certify/certify.server.ts": 1, - "src/lib/cloud-skills/bundle.functions.ts": 3, - "src/lib/cloud-skills/cloud-skills.functions.ts": 22, - "src/lib/cloud-skills/history.functions.ts": 7, - "src/lib/cloud-skills/subscription-guard.ts": 1, - "src/lib/cloud-skills/sync-log.server.ts": 1, - "src/lib/credits/credits.functions.ts": 6, - "src/lib/crm/crm.functions.ts": 9, - "src/lib/crm/learning.server.ts": 8, - "src/lib/crm/mailer.server.ts": 2, - "src/lib/crm/snapshot.server.ts": 3, - "src/lib/earnings/earnings.functions.ts": 2, - "src/lib/email-templates/action-confirmation.tsx": 1, - "src/lib/email-templates/crm-lifecycle.tsx": 1, - "src/lib/email-templates/registry.ts": 3, - "src/lib/experiments/uplift.functions.ts": 12, - "src/lib/favorites/favorites.functions.ts": 4, - "src/lib/growth/bounties.functions.ts": 2, - "src/lib/growth/tips.functions.ts": 1, - "src/lib/marketplace/detail.functions.ts": 1, - "src/lib/marketplace/discover.functions.ts": 4, - "src/lib/marketplace/list.functions.ts": 1, - "src/lib/marketplace/telemetry.functions.ts": 1, - "src/lib/marketplace/trust.functions.ts": 2, - "src/lib/match/match.functions.ts": 1, - "src/lib/mcp/http.server.ts": 3, - "src/lib/mcp/idempotency.ts": 1, - "src/lib/mcp/tools/agents.ts": 2, - "src/lib/mcp/tools/cloud-skills.ts": 2, - "src/lib/mcp/tools/cloud-sync.ts": 5, - "src/lib/mcp/tools/onboarding.ts": 6, - "src/lib/mcp/tools/returning.ts": 11, - "src/lib/mcp/tools/skills.ts": 14, - "src/lib/mcp/tools/university.ts": 2, - "src/lib/oauth/connections.functions.ts": 7, - "src/lib/oauth/mcp-oauth.functions.ts": 3, - "src/lib/packs/packs.functions.ts": 17, - "src/lib/payments.functions.ts": 3, - "src/lib/plugins/conformance.ts": 7, - "src/lib/plugins/package.server.ts": 1, - "src/lib/plugins/upload.functions.ts": 1, - "src/lib/referrals/referrals.functions.ts": 3, - "src/lib/reviews/anti-fraud.server.ts": 1, - "src/lib/reviews/reports.functions.ts": 1, - "src/lib/reviews/reviews.functions.ts": 1, - "src/lib/runs/shared.functions.ts": 1, - "src/lib/search/packages.server.ts": 3, - "src/lib/share/share-promo.functions.ts": 1, - "src/lib/site-stats.functions.ts": 1, - "src/lib/skills/author.functions.ts": 2, - "src/lib/skills/autolearn.functions.ts": 2, - "src/lib/skills/compatibility.functions.ts": 3, - "src/lib/skills/drift.functions.ts": 2, - "src/lib/skills/evaluator.functions.ts": 2, - "src/lib/skills/feedback.functions.ts": 2, - "src/lib/skills/forge-loop.functions.ts": 5, - "src/lib/skills/fork.functions.ts": 1, - "src/lib/skills/list.functions.ts": 2, - "src/lib/skills/review-history.server.ts": 2, - "src/lib/souls/get.functions.ts": 5, - "src/lib/souls/manage.functions.ts": 10, - "src/lib/stripe.server.ts": 4, - "src/lib/telemetry/funnel.server.ts": 1, - "src/lib/tenant-crypto/keys.functions.ts": 2, - "src/lib/tenant-crypto/keys.server.ts": 2, - "src/lib/university/university.functions.ts": 2, - "src/lib/university/university.server.ts": 4, - "src/lib/uploads/queue.server.ts": 2, - "src/lib/uploads/uploads.functions.ts": 3, - "src/lib/uploads/uploads.server.ts": 4, - "src/routes/account.agents.tsx": 3, - "src/routes/account.billing.tsx": 1, - "src/routes/account.cloud-skills.tsx": 10, - "src/routes/account.connections.tsx": 1, - "src/routes/account.encryption.tsx": 1, - "src/routes/account.library.tsx": 1, - "src/routes/account.packages.tsx": 7, - "src/routes/account.tokens.tsx": 2, - "src/routes/admin.accounts.tsx": 2, - "src/routes/admin.calibration.tsx": 2, - "src/routes/admin.crm.tsx": 4, - "src/routes/admin.customers.tsx": 5, - "src/routes/admin.meta-ads-pack.tsx": 4, - "src/routes/admin.packages.tsx": 1, - "src/routes/admin.plans.tsx": 1, - "src/routes/admin.requests.tsx": 1, - "src/routes/admin.review.tsx": 2, - "src/routes/admin.roi.tsx": 1, - "src/routes/admin.tsx": 1, - "src/routes/agents.$slug.tsx": 2, - "src/routes/agents.build.$id.tsx": 13, - "src/routes/agents.new.tsx": 2, - "src/routes/api/admin/ai-gateway-probe.ts": 2, - "src/routes/api/agents.$slug.download.$ext.ts": 1, - "src/routes/api/agents.build.$id.download.$ext.ts": 1, - "src/routes/api/badges.trust.$slug.svg.ts": 1, - "src/routes/api/jobs/drain-upload-queue.ts": 1, - "src/routes/api/mcp/health.ts": 1, - "src/routes/api/og.skill-of-the-week.svg.ts": 1, - "src/routes/api/packages.upload.ts": 2, - "src/routes/api/packs.customization.$id.download.$ext.ts": 3, - "src/routes/api/public/crm/c.$token.ts": 1, - "src/routes/api/public/crm/o.$token.ts": 1, - "src/routes/api/public/curriculum/next.ts": 1, - "src/routes/api/public/diagnose/submit.ts": 1, - "src/routes/api/public/hooks/prewarm-share-promos.ts": 1, - "src/routes/api/public/oauth/register.ts": 1, - "src/routes/api/public/oauth/revoke.ts": 1, - "src/routes/api/public/oauth/token.ts": 1, - "src/routes/api/public/onboarding.ts": 2, - "src/routes/api/public/packages.$slug.ts": 1, - "src/routes/api/public/packages.ts": 1, - "src/routes/api/public/payments/webhook.ts": 12, - "src/routes/api/public/plugins[.]json.ts": 3, - "src/routes/api/public/telemetry.ts": 3, - "src/routes/api/skills.$slug.export.ts": 1, - "src/routes/api/skills.$slug.export[.]md.ts": 1, - "src/routes/api/telemetry.ts": 2, - "src/routes/bounties.$id.tsx": 1, - "src/routes/bounties.tsx": 1, - "src/routes/collections.$slug.tsx": 1, - "src/routes/compare.$pair.tsx": 1, - "src/routes/curriculum.tsx": 3, - "src/routes/diagnose.tsx": 6, - "src/routes/forge.report.$slug.tsx": 1, - "src/routes/forge.tsx": 3, - "src/routes/leaderboard.tsx": 1, - "src/routes/llms[.]txt.ts": 1, - "src/routes/lovable/email/auth/preview.ts": 1, - "src/routes/lovable/email/auth/webhook.ts": 2, - "src/routes/lovable/email/queue/process.ts": 5, - "src/routes/lovable/email/transactional/send.ts": 1, - "src/routes/packs.$slug.customize.tsx": 9, - "src/routes/packs.$slug.tsx": 1, - "src/routes/run.$slug.tsx": 1, - "src/routes/sitemap[.]xml.tsx": 1, - "src/routes/skill-of-the-week.tsx": 1, - "src/routes/souls.$slug.tsx": 15, - "src/routes/use-case.$vertical.$task.tsx": 2 -} diff --git a/src/components/StripeEmbeddedCheckout.tsx b/src/components/StripeEmbeddedCheckout.tsx index b0ccba21..3d4004dd 100644 --- a/src/components/StripeEmbeddedCheckout.tsx +++ b/src/components/StripeEmbeddedCheckout.tsx @@ -1,3 +1,4 @@ +import { errorField } from "@/lib/errors"; import { useCallback, useState } from "react"; import { EmbeddedCheckoutProvider, EmbeddedCheckout } from "@stripe/react-stripe-js"; import { getStripe, getStripeEnvironment } from "@/lib/stripe"; @@ -25,8 +26,8 @@ export function StripeEmbeddedCheckout({ priceId, quantity, returnUrl }: Props) if ("error" in result) throw new Error(result.error); setError(null); return result.clientSecret; - } catch (e: any) { - const message = e?.message ?? "Could not create checkout session"; + } catch (e) { + const message = errorField(e, "message") ?? "Could not create checkout session"; setError(message); throw new Error(message); } diff --git a/src/components/agents/GuidedAgentEditor.tsx b/src/components/agents/GuidedAgentEditor.tsx index 7c559327..12648446 100644 --- a/src/components/agents/GuidedAgentEditor.tsx +++ b/src/components/agents/GuidedAgentEditor.tsx @@ -1,3 +1,4 @@ +import { errorField } from "@/lib/errors"; import { useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useServerFn } from "@tanstack/react-start"; @@ -165,8 +166,12 @@ export function GuidedAgentEditor({ : "Changes saved.", ); onDone?.(); - } catch (e: any) { - toast.error(typeof e?.message === "string" ? e.message : "Could not save your changes."); + } catch (e) { + toast.error( + typeof errorField(e, "message") === "string" + ? errorField(e, "message")! + : "Could not save your changes.", + ); } finally { setSaving(false); } diff --git a/src/components/cloud-skills/ExportBundle.tsx b/src/components/cloud-skills/ExportBundle.tsx index 3f34e5ff..80f8c7a6 100644 --- a/src/components/cloud-skills/ExportBundle.tsx +++ b/src/components/cloud-skills/ExportBundle.tsx @@ -62,7 +62,7 @@ export function ExportBundle({ `${r.skill_count} skill${r.skill_count === 1 ? "" : "s"} exported for ${r.tool.label}`, ); }, - onError: (e: any) => toast.error(e?.message ?? "Export failed"), + onError: (e) => toast.error(e?.message ?? "Export failed"), }); const toggle = (id: string) => diff --git a/src/components/cloud-skills/SyncHistory.tsx b/src/components/cloud-skills/SyncHistory.tsx index 6c0167d3..dbe94a62 100644 --- a/src/components/cloud-skills/SyncHistory.tsx +++ b/src/components/cloud-skills/SyncHistory.tsx @@ -76,7 +76,7 @@ export function SyncHistory() { enabled: !!openId, }); - const usedProviders = new Set((summary.data?.targets ?? []).map((t: any) => t.provider)); + const usedProviders = new Set((summary.data?.targets ?? []).map((t) => t.provider)); const filterable = PROVIDERS.filter((p) => usedProviders.has(p.id)); return ( @@ -93,7 +93,7 @@ export function SyncHistory() { {/* Per-target rollup */} {summary.data?.targets?.length ? (
- {summary.data.targets.map((t: any) => ( + {summary.data.targets.map((t) => (
- setStatus(SoulStatus.parse(v))}> @@ -718,7 +724,7 @@ function buildSoulMarkdown(soul: SoulDetail): string { ]; if (Array.isArray(c?.examples) && c!.examples.length) { sections.push("", "## Examples", ""); - c!.examples.forEach((ex: any, i: number) => { + c!.examples.forEach((ex, i: number) => { sections.push(`### Example ${i + 1}${ex.title ? ` — ${ex.title}` : ""}`); if (ex.input) sections.push("", "**Input:**", "", "```", String(ex.input), "```"); if (ex.expected_output) diff --git a/src/routes/status.tsx b/src/routes/status.tsx index 9e02eafa..fbd8feec 100644 --- a/src/routes/status.tsx +++ b/src/routes/status.tsx @@ -1,3 +1,4 @@ +import { errorField } from "@/lib/errors"; import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { SitePage } from "@/components/site/SitePage"; diff --git a/src/routes/unsubscribe.tsx b/src/routes/unsubscribe.tsx index 840e730d..e4422264 100644 --- a/src/routes/unsubscribe.tsx +++ b/src/routes/unsubscribe.tsx @@ -1,3 +1,4 @@ +import { errorField } from "@/lib/errors"; import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useState } from "react"; diff --git a/src/routes/use-case.$vertical.$task.tsx b/src/routes/use-case.$vertical.$task.tsx index 1a431512..fccd465d 100644 --- a/src/routes/use-case.$vertical.$task.tsx +++ b/src/routes/use-case.$vertical.$task.tsx @@ -3,7 +3,7 @@ import { Sparkles } from "lucide-react"; import { SitePage } from "@/components/site/SitePage"; import { EmptyState } from "@/components/site/EmptyState"; import { supabaseAdmin as _supabaseAdmin } from "@/integrations/supabase/client.server"; -const supabaseAdmin = _supabaseAdmin as any; +const supabaseAdmin = _supabaseAdmin; import { findUseCase } from "@/lib/seo/use-cases"; type SkillRow = { @@ -37,7 +37,7 @@ export const Route = createFileRoute("/use-case/$vertical/$task")({ : { data: [] }; const trustMap = new Map( (trust ?? []).map( - (t: { package_id: string; score: number; adversarial_pass_rate: number }) => [ + (t: { package_id: string; score: number; adversarial_pass_rate: number | null }) => [ t.package_id, t, ], @@ -53,7 +53,7 @@ export const Route = createFileRoute("/use-case/$vertical/$task")({ description: string | null; tags: string[] | null; }) => { - const t = trustMap.get(p.id) as any; + const t = trustMap.get(p.id); return { slug: p.slug, name: p.name, diff --git a/tests/typed-boundaries.test.ts b/tests/typed-boundaries.test.ts new file mode 100644 index 00000000..379d00b7 --- /dev/null +++ b/tests/typed-boundaries.test.ts @@ -0,0 +1,192 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import { parseEnvelope } from "../packages/sdk-ts/src/protocol.ts"; +import { errorField } from "../src/lib/errors.ts"; +import { toJson, recordOf } from "../src/lib/json.ts"; +import { JsonSchema } from "../src/lib/json-schema.ts"; +import { QueuedEmailSchema } from "../src/lib/email-templates/queue-payload.ts"; +import { decodeAgentBuild, encodeAgentBuildPatch } from "../src/lib/agents/build-data.ts"; +import { verifyWebhook } from "../src/lib/stripe.server.ts"; +import { ExecutionEventSchema, ingestExecution } from "../src/lib/trust/telemetry.ts"; +import { telemetrySink } from "../src/lib/trust/telemetry-sink.server.ts"; +import type { AppDatabaseClient, Row } from "../src/integrations/supabase/database.ts"; + +test("RPC envelopes tolerate invalid JSON and ignore malformed content entries", () => { + assert.equal(parseEnvelope("invalid"), null); + assert.equal(parseEnvelope("null"), null); + const result = parseEnvelope( + JSON.stringify({ + result: { + content: [null, 3, { type: "text", text: "ok" }, { type: 4 }, { type: "image", text: 5 }], + isError: false, + }, + }), + ); + assert.deepEqual(result?.result?.content, [ + { type: "text", text: "ok" }, + { type: "image", text: undefined }, + ]); + assert.equal(result?.result?.isError, false); + assert.deepEqual( + parseEnvelope('{"error":{"message":"Denied","code":401,"data":{"tokens_url":"x"}}}')?.error, + { message: "Denied", code: 401, data: { tokens_url: "x" } }, + ); +}); +test("unknown errors and JSON retain values without granting arbitrary property access", () => { + assert.equal(errorField(new Error("bad")), "bad"); + assert.equal(errorField({ message: 12 }), undefined); + assert.deepEqual(recordOf(null), {}); + assert.deepEqual(recordOf([]), {}); + assert.deepEqual(toJson({ date: new Date("2026-01-01T00:00:00Z"), missing: undefined }), { + date: "2026-01-01T00:00:00.000Z", + }); + assert.throws(() => toJson(undefined)); + assert.equal(JsonSchema.safeParse({ n: Infinity }).success, false); +}); +test("queue payload rejects malformed recipients and accepts existing valid messages", () => { + const message = { + to: "user@example.test", + from: "App ", + subject: "Subject", + html: "

ok

", + text: "ok", + queued_at: "2026-01-01T00:00:00Z", + }; + assert.deepEqual(QueuedEmailSchema.parse(message), message); + assert.equal(QueuedEmailSchema.safeParse({ ...message, to: 42 }).success, false); +}); +test("agent build JSON is validated and encoded without dropping typed content", () => { + const row = { + brief: { role: "Analyst", company: "Example" }, + skills: [], + playbooks: [], + guardrails: [], + research_sources: [], + report: { principles: ["Verify"], first_pass: null }, + } as unknown as Row<"agent_builds">; + const decoded = decodeAgentBuild(row); + assert.deepEqual(decoded.report.principles, ["Verify"]); + assert.equal(decoded.report.first_pass, null); + assert.deepEqual(encodeAgentBuildPatch({ report: decoded.report, skills: decoded.skills }), { + report: row.report, + skills: [], + }); + assert.throws(() => decodeAgentBuild({ ...row, skills: [{ body: "Missing required fields" }] })); +}); +test("telemetry writes exactly once to the authoritative RPC with typed outcome fields", async () => { + const calls: Array<{ name: string; args: Record }> = []; + const client = { + rpc: async (name: string, args: Record) => { + calls.push({ name, args }); + return { data: "execution-id", error: null }; + }, + } as unknown as AppDatabaseClient; + const event = ExecutionEventSchema.parse({ + package_slug: "test-skill", + workspace_id: "private-workspace", + success: false, + error_class: "timeout", + arm: "treatment", + task_completed: false, + user_rating: 0, + }); + const row = await ingestExecution(event, telemetrySink(client, "model-fixture")); + assert.equal(calls.length, 1); + assert.equal(calls[0].name, "report_skill_execution"); + assert.equal(calls[0].args._error_kind, "timeout"); + assert.equal(calls[0].args._model, "model-fixture"); + assert.equal(calls[0].args._task_completed, false); + assert.equal(calls[0].args._user_rating, 0); + assert.equal(calls[0].args._workspace_hash, row.workspace_hash); + assert.notEqual(row.workspace_hash, "private-workspace"); + assert.equal("package_version" in calls[0].args, false); +}); +test("Stripe verification preserves signed event and rejects invalid signature/timestamp", async () => { + const secret = "fixture-secret"; + const old = process.env.PAYMENTS_SANDBOX_WEBHOOK_SECRET; + process.env.PAYMENTS_SANDBOX_WEBHOOK_SECRET = secret; + try { + const payload = JSON.stringify({ + id: "evt_fixture", + type: "customer.subscription.deleted", + data: { object: { id: "sub_fixture", object: "subscription" } }, + }); + const request = (timestamp: string, body = payload, signature?: string) => + new Request("https://example.test/webhook", { + method: "POST", + body, + headers: { + "stripe-signature": `t=${timestamp},v1=${signature ?? createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex")}`, + }, + }); + const timestamp = String(Math.floor(Date.now() / 1000)); + assert.equal((await verifyWebhook(request(timestamp), "sandbox")).id, "evt_fixture"); + await assert.rejects(() => verifyWebhook(request(timestamp, payload, "invalid"), "sandbox")); + await assert.rejects(() => verifyWebhook(request("NaN"), "sandbox")); + await assert.rejects(() => verifyWebhook(request("1"), "sandbox")); + await assert.rejects(() => + verifyWebhook(request(timestamp, '{"type":"event","data":null}'), "sandbox"), + ); + } finally { + if (old === undefined) delete process.env.PAYMENTS_SANDBOX_WEBHOOK_SECRET; + else process.env.PAYMENTS_SANDBOX_WEBHOOK_SECRET = old; + } +}); + +test("Stripe gateway retains pinned API version and authentication headers without network", async () => { + const { createStripeClient } = await import("../src/lib/stripe.server.ts"); + const previousFetch = globalThis.fetch; + const oldConnection = process.env.STRIPE_SANDBOX_API_KEY; + const oldLovable = process.env.LOVABLE_API_KEY; + process.env.STRIPE_SANDBOX_API_KEY = "fixture-connection"; + process.env.LOVABLE_API_KEY = "fixture-lovable"; + const requests: Array<{ url: string; headers: Headers }> = []; + globalThis.fetch = async (input, init) => { + requests.push({ url: String(input), headers: new Headers(init?.headers) }); + return Response.json({ object: "list", data: [], has_more: false, url: "/v1/prices" }); + }; + try { + await createStripeClient("sandbox").prices.list({ limit: 1 }); + assert.equal(requests.length, 1); + assert.ok(requests[0].url.startsWith("https://connector-gateway.lovable.dev/stripe/v1/prices")); + assert.equal(requests[0].headers.get("stripe-version"), "2026-03-25.dahlia"); + assert.equal(requests[0].headers.get("x-connection-api-key"), "fixture-connection"); + assert.equal(requests[0].headers.get("lovable-api-key"), "fixture-lovable"); + } finally { + globalThis.fetch = previousFetch; + if (oldConnection === undefined) delete process.env.STRIPE_SANDBOX_API_KEY; + else process.env.STRIPE_SANDBOX_API_KEY = oldConnection; + if (oldLovable === undefined) delete process.env.LOVABLE_API_KEY; + else process.env.LOVABLE_API_KEY = oldLovable; + } +}); + +test("reauthentication validates its token without requiring an unused confirmation URL", async () => { + const { AuthEmailDataSchema } = await import("../src/lib/email-templates/auth-data.ts"); + assert.equal( + AuthEmailDataSchema.safeParse({ + action_type: "reauthentication", + email: "user@example.test", + token: "123456", + }).success, + true, + ); + assert.equal( + AuthEmailDataSchema.safeParse({ action_type: "reauthentication", email: "user@example.test" }) + .success, + false, + ); + assert.equal( + AuthEmailDataSchema.safeParse({ action_type: "signup", email: "user@example.test" }).success, + false, + ); + assert.equal( + AuthEmailDataSchema.safeParse({ + action_type: "signup", + email: "user@example.test", + url: "https://example.test/confirm", + }).success, + true, + ); +}); diff --git a/tsconfig.json b/tsconfig.json index 5b1f8339..d4d97e29 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,7 @@ /* Linting */ "skipLibCheck": true, "strict": true, - "noImplicitAny": false, + "noImplicitAny": true, "noUnusedLocals": false, "noUnusedParameters": false, "noFallthroughCasesInSwitch": true,