Repository navigation
Conversation
Introduce response security headers and strengthen YUCP signing/verification and Convex actor handling. - Add applyResponseSecurityHeaders middleware to set Referrer-Policy, X-Content-Type-Options and no-store for JSON responses; integrate into API server and adjust tests. - Enhance yucp runtime crypto to verify JWTs against pinned trust roots, resolve a pinned signing root from the configured private key, and expose helpers for pinned-root verification. - Update coupling license routes to use pinned signing roots and return 503 when configured root isn't pinned. - Add actor binding support to Convex clients: inject service actor bindings for protected functions in server convex client and add a bot-specific convex client that attaches service actors; wire usage into bot startup and role-sync service. - Harden forensics route handling: sanitize uploaded filenames, resolve safe workspace paths, limit filename length, and stop leaking sensitive fields (licenseKey, purchaserEmail) in responses. - Add cleanup script to remove deprecated "impeccable" skill names and clean skills-lock.json entries. - Update and add tests/realtests to cover pinned roots, auth binding, forensics filename sanitization, and response header expectations. These changes improve security (HTTP headers and cache control), ensure YUCP keys are validated against pinned anchors, and enable Convex calls to include authenticated service actor bindings for protected operations.
Add utilities and plumbing to attach ApiActor bindings to protected Convex requests when an apiSecret is present. Introduce createAuthUserActorBinding and createApiServiceActorBinding (with test default secret) in apps/api and use them in API routes to supply actorBinding to getConvexClientFromUrl. Update Convex client resolution to only attempt actor injection when args is an object containing apiSecret, and add shouldAttachActor helper to bot and test clients to centralize that logic. Update entitlements queries to accept an actor arg and normalize function names in isApiActorProtectedFunction (convert ':' to '.') so protected-function detection works with different name formats.
Add and use ApiActor bindings across the codebase and harden actor utilities. Key changes: - Introduce createServiceActorBinding and getRequiredBotActorBinding to create/require service/bot actor bindings (convex/lib and apps/bot/lib). - Update many bot commands to obtain a required bot actor and attach the actor to Convex queries/mutations/actions to ensure protected Convex calls include actor context. - Create and use service actor bindings in convex/http and convex/backgroundSync for server-side protected calls. - Update API route tests to include a mock actorBinding. - Improve test harness (convex/testHelpers) to properly inject actor bindings, fix types, and simplify client wrappers. - Harden shared apiActor parsing/serialization: validate numeric fields, normalize scope arrays, and fix base64 padding/formatting. - Reorder and export apiActor from packages/shared index; add explicit error when no pinned YUCP roots exist. - Minor formatting/whitespace and small refactors to improve readability. These changes ensure protected Convex operations consistently include actor context and make actor payload parsing and utilities more robust.
Replace hard-coded/pinned YUCP root helpers with resolution of a configured YUCP trust bundle (from YUCP_TRUST_BUNDLE_JSON). Add helpers to derive JWK sets from configured roots and to sign/verify authenticated trust-bundle JWTs for root rotation. Update runtime and crypto code to use the configured trust bundle when resolving/validating keys and when resolving the pinned signing root; improve error messages accordingly. Expose the configured JWK set and a signed trust bundle from the /v1/keys HTTP endpoint. Add tests for trust-bundle signing/verification and update existing tests to reflect the new error text and behavior. Also update Convex-generated types to include new modules and extend test helpers to support injecting an ApiActor in typed query/mutation/action helpers. Remove an unused runtime-artifact mock in a realtest.
When signing the YUCP trust bundle JWT, prefer resolveConfiguredPublicApiBaseUrl() as the issuer and fall back to requestUrl.origin if not set. This makes the token issuer reflect a configured public API base URL (useful for custom domains/proxies). Updated the related contract test and build metadata.
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughIntroduce HMAC-signed API actor bindings, actor-aware Convex clients and authorization across many Convex endpoints, pinned YUCP trust-bundle signing/verification, HTTP security/rate-limiting hardening, PII encryption/migration, expanded tests and contracts, a cleanup script for deprecated Impeccable skills, and a skills-lock registry. Changes
Sequence DiagramsequenceDiagram
participant Client as Client / Bot
participant API as API Server
participant Convex as Convex Backend
participant ActorSrv as ApiActor (creator/verifier)
participant Trust as Trust Bundle Manager
participant Crypto as Crypto
Client->>API: HTTP request (apiSecret / credentials)
API->>ActorSrv: request/create actor binding (createApiActorBinding)
ActorSrv->>Crypto: HMAC sign canonical payload
Crypto-->>ActorSrv: ApiActorBinding {payload, signature}
ActorSrv-->>API: actor binding returned
API->>Convex: Query/Mutation (args + actor)
Convex->>ActorSrv: verifyApiActorBinding(actor, secret)
ActorSrv->>Crypto: verify HMAC & timestamps
Crypto-->>ActorSrv: parsed ApiActor
ActorSrv-->>Convex: validated actor (kind, scopes)
alt Pinned-root crypto required
Convex->>Trust: getPinnedYucpRootByKeyId(kid)
Trust-->>Convex: pinned root (publicKey, keyId)
Convex->>Crypto: verify/sign JWT with Ed25519 root
Crypto-->>Convex: verify/sign result
end
Convex-->>API: Response
API->>API: applyResponseSecurityHeaders + rate-limit
API-->>Client: Response (security headers applied)
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Suggested labels
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Pull request overview
This PR hardens HTTP responses, adds actor-signed Convex access for protected endpoints, and introduces pinned YUCP trust-bundle/root handling for JWT verification and root rotation.
Changes:
- Apply baseline security headers to API HTTP responses and tighten proxy/header handling + add rate limits on Convex HTTP surface.
- Introduce
ApiActorBindinggeneration/verification and require actor bindings for protected Convex functions (API server + bot + tests updated). - Add pinned YUCP trust roots + configurable trust bundle parsing and use pinned-root verification paths across Convex + API runtime JWT verification.
Reviewed changes
Copilot reviewed 74 out of 76 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| skills-lock.json | Adds lockfile entries for Impeccable skills. |
| packages/shared/test/authOrigins.test.ts | Updates expectations for explicit loopback origins. |
| packages/shared/src/authOrigins.ts | Replaces localhost wildcard patterns with explicit loopback origins. |
| packages/shared/src/apiActor.ts | Adds shared API actor binding format + HMAC signing/verification utilities. |
| packages/shared/src/index.ts | Exports new shared modules (apiActor/yucpTrust). |
| packages/shared/src/yucpTrust.ts | Adds pinned root definitions + trust bundle parsing/helpers. |
| packages/shared/src/yucpTrust.test.ts | Unit tests for pinned root/trust bundle behavior. |
| convex/yucpTrustBundle.contract.test.ts | Contract assertions for trust bundle HTTP surface behavior. |
| convex/yucpLicenses.ts | Uses pinned-root signing/verification paths; adds bounds and encryption hardening. |
| convex/yucpLicenses.realtest.ts | Sets pinned test roots for realtests. |
| convex/yucpCertificates.ts | Uses pinned-root signing for cert issuance. |
| convex/vitest.config.ts | Adds INTERNAL_SERVICE_AUTH_SECRET for realtests. |
| convex/verificationSessions.ts | Requires actor binding + scope checks for API access. |
| convex/verificationIntents.ts | Requires actor binding + pinned-root verification for grants; uses pinned signing key. |
| convex/verificationIntents.realtest.ts | Sets pinned test roots; updates setup for new trust model. |
| convex/testHelpers.ts | Injects default service actor binding for protected Convex calls in tests. |
| convex/subjects.ts | Adds actor enforcement + tighter tenant/binding checks for subject lookups. |
| convex/protectedUnlock.realtest.ts | Sets pinned test roots for protected unlock flow. |
| convex/protectedMaterializationGrant.realtest.ts | Sets pinned test roots for grant flow. |
| convex/protectedInstallIntent.realtest.ts | Sets pinned test roots for install intent flow. |
| convex/protectedBlobArchitecture.realtest.ts | Sets pinned test roots; updates coupling runtime request shape. |
| convex/protectedAssetsBounds.realtest.ts | Tests request size bounds for protected assets. |
| convex/packageRegistry.ts | Removes ownership disclosure on namespace conflicts; adds actor enforcement on public APIs. |
| convex/packageRegistry.realtest.ts | Tests conflict privacy + adds actor binding to public API calls. |
| convex/migrations.ts | Adds migration to encrypt legacy license keys and drop redundant fields. |
| convex/migrations.realtest.ts | Tests new migration + new-write hardening. |
| convex/manualLicenses.ts | Adds actor enforcement and bulk request bounds. |
| convex/manualLicenses.realtest.ts | Tests bulkCreate bound enforcement. |
| convex/lib/yucpTrustBundle.test.ts | Tests signing/verifying trust bundle JWTs. |
| convex/lib/yucpCrypto.ts | Adds trust-bundle aware verification and pinned signing root resolution. |
| convex/lib/yucpCrypto.test.ts | Minor formatting change. |
| convex/lib/trustedOrigins.ts | Mirrors explicit loopback origin allowlist behavior. |
| convex/lib/trustedOrigins.realtest.ts | Updates origin tests for explicit loopback list. |
| convex/lib/ownership.ts | Adds shared ownership/binding helpers. |
| convex/lib/licenseSubjectLink.ts | Stops persisting plaintext license keys/emails. |
| convex/lib/apiActor.ts | Adds Convex-side actor binding verification + scope checks. |
| convex/httpSurface.contract.test.ts | Contracts for privacy, header allowlists, and rate limiting in HTTP surface. |
| convex/httpCertificates.contract.test.ts | Ensures cert issuance errors are not leaked verbatim. |
| convex/http.ts | Hardens responses/headers, adds rate limits, adds trustBundle to /v1/keys, uses service actor binding for protected Convex calls, and removes sensitive fields from public responses. |
| convex/entitlements.ts | Adds actor enforcement to entitlement APIs. |
| convex/downloads.ts | Adds actor enforcement + ownership checks for routes/artifacts. |
| convex/couplingForensics.ts | Removes plaintext license key/email from forensics responses. |
| convex/couplingForensics.realtest.ts | Verifies forensics responses no longer include plaintext PII. |
| convex/backgroundSync.ts | Uses service actor binding for protected Convex API calls. |
| convex/authBinding.realtest.ts | Tests that protected APIs reject calls without actor binding. |
| convex/_generated/api.d.ts | Updates generated API typings for new modules. |
| apps/bot/src/services/roleSync.ts | Switches to actor-injecting Convex client wrapper. |
| apps/bot/src/lib/env.ts | Requires INTERNAL_SERVICE_AUTH_SECRET for bot runtime. |
| apps/bot/src/lib/convexActor.ts | Adds bot Convex client wrapper that auto-injects actor bindings. |
| apps/bot/src/index.ts | Uses actor-injecting Convex client wrapper. |
| apps/bot/src/commands/verify.ts | Passes actor binding on protected Convex calls. |
| apps/bot/src/commands/suspicious.ts | Passes actor binding on protected Convex calls. |
| apps/bot/src/commands/status.ts | Passes actor binding on protected Convex calls. |
| apps/bot/src/commands/stats.ts | Passes actor binding on protected Convex calls. |
| apps/bot/src/commands/moderation.ts | Passes actor binding on protected Convex calls. |
| apps/bot/src/commands/licenseVerify.ts | Passes actor binding on protected Convex calls. |
| apps/bot/src/commands/downloads.ts | Passes actor binding on protected Convex calls. |
| apps/bot/src/commands/analytics.ts | Passes actor binding on protected Convex calls. |
| apps/api/test/index.test.ts | Verifies new default security headers on JSON responses. |
| apps/api/src/routes/publicV2/webhooks.test.ts | Updates auth mocks to include actor binding. |
| apps/api/src/routes/publicV2/verification-intents.test.ts | Updates auth mocks to include actor binding. |
| apps/api/src/routes/publicV2/collaborators.test.ts | Updates auth mocks to include actor binding. |
| apps/api/src/routes/publicV2/auth.ts | Auth resolution now returns actor binding for protected Convex calls. |
| apps/api/src/routes/packages.ts | Injects actor binding into Convex client creation for package APIs. |
| apps/api/src/routes/forensics.ts | Sanitizes upload filenames + removes plaintext PII propagation. |
| apps/api/src/routes/forensics.test.ts | Tests filename sanitization + confirms plaintext PII not returned. |
| apps/api/src/routes/couplingLicenses.ts | Uses pinned-root verification and pinned signing root resolution. |
| apps/api/src/routes/couplingLicenses.test.ts | Tests pinned-root mismatch fails closed; sets pinned roots in tests. |
| apps/api/src/lib/yucpRuntimeCrypto.ts | Adds trust-bundle aware verification + pinned signing root resolution. |
| apps/api/src/lib/httpSecurity.ts | Adds shared response security header utility. |
| apps/api/src/lib/convex.ts | Adds actor-aware Convex client wrapper with default service actor caching. |
| apps/api/src/lib/apiActor.ts | Adds API-side actor binding creation helpers. |
| apps/api/src/index.ts | Applies response security headers to all responses. |
| apps/api/src/createServer.ts | Applies response security headers to test/dev server responses. |
| .agents/skills/impeccable/scripts/cleanup-deprecated.mjs | Adds a cleanup script for deprecated Impeccable skills/lock entries. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async function signApiActorValue(secret: string, value: string): Promise<string> { | ||
| const key = await crypto.subtle.importKey( | ||
| 'raw', | ||
| new TextEncoder().encode(secret), | ||
| { name: 'HMAC', hash: 'SHA-256' }, | ||
| false, | ||
| ['sign'] | ||
| ); | ||
| const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(value)); | ||
| return bytesToHex(new Uint8Array(signature)); | ||
| } |
There was a problem hiding this comment.
signApiActorValue imports the HMAC CryptoKey on every call. Since actor bindings are verified frequently, caching the imported CryptoKey per secret (or precomputing it in the caller) would avoid repeated crypto.subtle.importKey overhead.
| function normalizeTrustJwk(root: YucpPinnedRoot): YucpTrustJwk { | ||
| return { | ||
| kty: 'OKP', | ||
| crv: 'Ed25519', | ||
| kid: root.keyId, | ||
| x: root.publicKeyBase64, | ||
| }; |
There was a problem hiding this comment.
YucpTrustJwk.x is populated with publicKeyBase64 (standard base64, includes +// and = padding). For JWK OKP keys, x should be base64url-encoded without padding; otherwise strict JWK/JWKS consumers may reject the key set. Consider emitting base64url here (and accepting both encodings when parsing configured bundles if needed).
| * 4. Removes the corresponding entries from skills-lock.json. | ||
| */ | ||
|
|
||
| import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs'; |
There was a problem hiding this comment.
This script imports readdirSync and statSync but doesn't use them, which will typically fail linting (unused imports). Remove the unused imports to keep the script clean and CI-friendly.
| import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs'; | |
| import { existsSync, readFileSync, writeFileSync, rmSync, lstatSync, unlinkSync } from 'node:fs'; |
| const payload = getNonEmptyString(binding.payload); | ||
| const signature = getNonEmptyString(binding.signature); | ||
| if (!payload || !signature) { | ||
| return null; | ||
| } | ||
|
|
||
| const actor = parseApiActorPayload(payload); | ||
| if (!actor) { | ||
| return null; | ||
| } | ||
|
|
||
| const expectedSignature = await signApiActorValue(secret, `${API_ACTOR_PREFIX}.${payload}`); | ||
| if (!timingSafeStringEqual(signature, expectedSignature)) { | ||
| return null; |
There was a problem hiding this comment.
In ApiActor bindings, signature is accepted as any non-empty string. Since HMAC-SHA256 signatures are always 32 bytes, consider validating signature is exactly a 64-char hex string (and optionally bounding payload length) before parsing/verifying to avoid expensive work/DoS from oversized inputs.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9681bbde8a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| signingRoot = await getPinnedSigningRoot( | ||
| process.env.YUCP_KEY_ID ?? process.env.YUCP_ROOT_KEY_ID ?? null | ||
| ); |
There was a problem hiding this comment.
Align root-key env precedence in signature registration
/v1/signatures resolves the signing root with YUCP_KEY_ID before YUCP_ROOT_KEY_ID, but /v1/keys does the opposite (YUCP_ROOT_KEY_ID first). During key rotation (when both vars are present), this can select different key IDs between endpoints, and because resolvePinnedYucpSigningRoot now fails closed, signature registration can return 503 even while /v1/keys is healthy. Use one consistent precedence order across handlers to avoid rotation-time outages.
Useful? React with 👍 / 👎.
| const realIp = request.headers.get('x-real-ip')?.trim(); | ||
| if (realIp) { | ||
| return realIp; | ||
| } | ||
|
|
||
| const forwardedFor = request.headers.get('x-forwarded-for'); | ||
| if (forwardedFor) { | ||
| return forwardedFor.split(',')[0]?.trim() || 'unknown'; |
There was a problem hiding this comment.
Use trusted source for rate-limit client IP
The limiter key accepts x-real-ip and the first x-forwarded-for value directly from request headers. In deployments where these headers are not rewritten by a trusted edge, clients can spoof them to evade per-IP throttles by rotating header values. Rate-limit identity should come from trusted proxy metadata only (or from a platform-provided remote address).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 12
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (8)
apps/bot/src/commands/licenseVerify.ts (2)
577-609:⚠️ Potential issue | 🟡 MinorSame unhandled error risk exists here.
The
getRequiredBotActorBinding()call at line 577 is outside the try-catch block starting at line 579, creating the same unhandled exception risk as inhandleVrchatCredentialsModal.🛡️ Suggested fix
const discordUserId = interaction.user.id; let subjectId: string | null = null; - const actor = await getRequiredBotActorBinding(); try { + const actor = await getRequiredBotActorBinding(); const ensureResult = await convex.mutation(api.subjects.ensureSubjectForDiscord, {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/bot/src/commands/licenseVerify.ts` around lines 577 - 609, The call to getRequiredBotActorBinding() can throw but is executed outside the try-catch that handles ensureSubjectForDiscord, creating an unhandled error path; move or duplicate getRequiredBotActorBinding() invocation into the same try block (or wrap it in its own try) so any thrown error is caught and handled the same way as errors from convex.mutation(api.subjects.ensureSubjectForDiscord), and ensure you still assign subjectId from ensureResult and run the same error branch (logging via logger.error and sending the same interaction.editReply paths) when getRequiredBotActorBinding() fails.
461-481:⚠️ Potential issue | 🟡 MinorPotential unhandled error if actor binding retrieval fails.
getRequiredBotActorBinding()is called outside the try-catch block. If it throws (e.g., missingINTERNAL_SERVICE_AUTH_SECRET), the error will propagate uncaught, potentially crashing the modal handler without a user-friendly message.🛡️ Suggested fix
await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const discordUserId = interaction.user.id; let subjectId: string | null = null; - const actor = await getRequiredBotActorBinding(); try { + const actor = await getRequiredBotActorBinding(); const ensureResult = await convex.mutation(api.subjects.ensureSubjectForDiscord, {Or wrap the entire block in try-catch with appropriate error handling for the actor binding failure case.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/bot/src/commands/licenseVerify.ts` around lines 461 - 481, getRequiredBotActorBinding() can throw before the existing try-catch around convex.mutation, so wrap the actor retrieval in the same error-handling path: move the call to getRequiredBotActorBinding() into the try block (or add a short try/catch around it) so any errors are caught, logged with logger.error (including the error and discordUserId), and the user is notified via interaction.editReply with the same friendly failure message; ensure you still call convex.mutation(api.subjects.ensureSubjectForDiscord, { actor, ... }) only after actor is successfully obtained and preserve the existing subjectId check and error handling.convex/http.ts (2)
1362-1414:⚠️ Potential issue | 🟠 MajorReject oversized protected-asset payloads before any writes.
The
MAX_PROTECTED_ASSETS_PER_REQUESTguard runs afterregisterPackage(...)andwriteEntry(...). A request can return 400 here after already registering the namespace and appending the transparency log entry, which leaves the client with a failure response for a partially committed mutation set.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@convex/http.ts` around lines 1362 - 1414, The protected-assets size check currently runs after performing mutations (internal.packageRegistry.registerPackage and internal.signingLog.writeEntry), risking partial commits; move the guard that checks body.protectedAssets.length against MAX_PROTECTED_ASSETS_PER_REQUEST to run before calling registerPackage or signingLog.writeEntry (i.e., at the top of this handler), and return the same errorResponse(...) 400 immediately if exceeded so no mutations are executed when the payload is oversized.
476-507:⚠️ Potential issue | 🟠 MajorUse the signer’s key ID when building the manifest chain.
buildManifestCertificateChain(...)signs the publisher cert withrootPrivateKey, butissuerKeyIdis taken fromenvelope.signature.keyId. If the active signing root ever differs from the device cert’s issuing root, the chain metadata no longer identifies the key that actually signed it.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@convex/http.ts` around lines 476 - 507, In buildManifestCertificateChain, the issuerKeyId is incorrectly taken from envelope.signature.keyId (rootKeyId) instead of the actual signer’s key id; after calling signPackageCertificateData(publisherCertificateBase, rootPrivateKey) capture the signature metadata and set issuerKeyId on publisherCertificateBase (and the returned publisher cert) to the signer's key id (e.g., the keyId from the publisherSignature/result of signPackageCertificateData) so the chain records the actual key that signed it (update buildManifestCertificateChain, publisherCertificateBase.issuerKeyId, and the returned certificate object accordingly).convex/yucpLicenses.ts (1)
1741-1760:⚠️ Potential issue | 🟠 MajorDon't require the private root key in verification-only paths.
These branches only verify pinned-root tokens, but
getPinnedSigningRoot(...)hard-requiresYUCP_ROOT_PRIVATE_KEYfirst. That makesredeemProtectedMaterializationGrantandissueCouplingJobfail in verify-only or staged-rotation environments even though neither path signs anything here. Gate them on the trust bundle only.Also applies to: 1973-1976
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@convex/yucpLicenses.ts` around lines 1741 - 1760, The verification-only paths (e.g., redeemProtectedMaterializationGrant and issueCouplingJob) are calling getPinnedSigningRoot(...) which currently requires the private root key; change these to load only the pinned trust bundle/public roots so verification does not depend on YUCP_ROOT_PRIVATE_KEY. Specifically, replace calls to getPinnedSigningRoot(process.env.YUCP_ROOT_KEY_ID ?? null) in the shown block (and the similar calls around lines 1973-1976) with a function or flag that returns only the public trust bundle/pinned roots (e.g., getPinnedTrustBundle or getPinnedSigningRoot({publicOnly:true})), and ensure downstream verification functions (verifyProtectedUnlockJwtAgainstPinnedRoots) still receive the same issuer/trust data but never trigger private-key loading or signing logic.convex/downloads.ts (2)
154-175:⚠️ Potential issue | 🟠 MajorValidate that
guildLinkIdactually belongs toguildId.Ownership alone is not enough here. A caller can use one of their own guild links and pair it with a different
guildId, which creates a route whose foreign key and denormalized guild metadata disagree.Proposed fix
const guildLink = await ctx.db.get(args.guildLinkId); requireOwnedDocument( guildLink, args.authUserId, 'Unauthorized: caller does not own this guild link' ); + if (guildLink.guildId !== args.guildId) { + throw new ConvexError('guildLinkId does not belong to guildId'); + } const routeId = await ctx.db.insert('download_routes', {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@convex/downloads.ts` around lines 154 - 175, After fetching guildLink with ctx.db.get and checking ownership via requireOwnedDocument, also verify that the retrieved guildLink's guildId matches args.guildId; if they differ, reject the request (throw an error or return a 400/403) before calling ctx.db.insert to create the download route. Update the logic around guildLink/guildLinkId validation in downloads.ts (the code that calls requireOwnedDocument and then inserts into 'download_routes') to perform this additional equality check and fail fast when guildId and guildLink.guildId are inconsistent.
311-333:⚠️ Potential issue | 🟠 MajorDon't trust route-derived artifact fields from the request body.
After fetching the route, the insert still takes
guildId, channel IDs, role IDs, and role logic fromargs. That lets an owned route authorize an artifact whose routing metadata disagrees with the route record, which can misroute delivery or break downstream lookups.Proposed fix
const route = await ctx.db.get(args.routeId); requireOwnedDocument(route, args.authUserId); const now = Date.now(); const artifactId = await ctx.db.insert('download_artifacts', { authUserId: args.authUserId, - guildId: args.guildId, + guildId: route.guildId, routeId: args.routeId, - sourceChannelId: args.sourceChannelId, + sourceChannelId: route.sourceChannelId, sourceMessageId: args.sourceMessageId, sourceMessageUrl: args.sourceMessageUrl, sourceAuthorId: args.sourceAuthorId, - archiveChannelId: args.archiveChannelId, + archiveChannelId: route.archiveChannelId, archiveMessageId: args.archiveMessageId, archiveThreadId: args.archiveThreadId, sourceRelayMessageId: args.sourceRelayMessageId, sourceDeliveryMode: args.sourceDeliveryMode, - requiredRoleIds: [...new Set(args.requiredRoleIds)], - roleLogic: args.roleLogic, + requiredRoleIds: route.requiredRoleIds, + roleLogic: route.roleLogic, files: args.files, status: args.status ?? 'active', createdAt: now,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@convex/downloads.ts` around lines 311 - 333, The insert is using routing metadata from args which allows inconsistent/malicious routing; change the ctx.db.insert('download_artifacts', ...) call to source all route-derived fields from the fetched route instead of args: use route.guildId, route.sourceChannelId, route.sourceMessageId, route.sourceMessageUrl, route.sourceAuthorId, route.archiveChannelId, route.archiveMessageId, route.archiveThreadId, route.sourceRelayMessageId, route.sourceDeliveryMode and use route.roleLogic and a de-duplicated route.requiredRoleIds (e.g. [...new Set(route.requiredRoleIds || [])]); keep authUserId, files, status, createdAt/updatedAt etc from args/context as appropriate and leave requireOwnedDocument(route, args.authUserId) in place.convex/entitlements.ts (1)
1387-1401:⚠️ Potential issue | 🟠 MajorUse a service-scoped actor for the scheduler mutation.
This mutation is documented as system-triggered, but it currently authorizes any delegated creator actor. That is broader than necessary for a bulk-revocation path and couples scheduled jobs to creator impersonation instead of an explicit service capability.
Proposed fix
import { ApiActorBindingV, requireDelegatedAuthUserActor, requireServiceActor, } from './lib/apiActor'; @@ handler: async (ctx, args) => { requireApiSecret(args.apiSecret); - await requireDelegatedAuthUserActor(args.actor, args.authUserId); + await requireServiceActor(args.actor, ['entitlements:service']); const now = Date.now();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@convex/entitlements.ts` around lines 1387 - 1401, Replace the delegated-creator authorization in this scheduler mutation: remove the call to requireDelegatedAuthUserActor(args.actor, args.authUserId) in the mutation handler and enforce a service-scoped actor check instead (use or add a helper such as requireServiceActor(args.actor) or requireSystemActor(args.actor) and call that before proceeding); keep the requireApiSecret(args.apiSecret) check and continue using Date.now() as before, and return the same payload shape (success, expiredCount, entitlementIds).
🧹 Nitpick comments (9)
convex/lib/trustedOrigins.ts (1)
6-19: Consider extracting shared loopback origins to avoid duplication.This list is duplicated in
packages/shared/src/authOrigins.ts. If the allowed ports change, both files need to be updated in sync, which is error-prone.♻️ Suggested approach
Consider extracting
LOCAL_TRUSTED_BROWSER_ORIGINSto@yucp/sharedand importing it here:// In packages/shared/src/authOrigins.ts - export the constant export const LOCAL_TRUSTED_BROWSER_ORIGINS = [...]; // In convex/lib/trustedOrigins.ts - import instead of duplicating import { LOCAL_TRUSTED_BROWSER_ORIGINS } from '@yucp/shared/authOrigins';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@convex/lib/trustedOrigins.ts` around lines 6 - 19, Extract the LOCAL_TRUSTED_BROWSER_ORIGINS constant into the shared package and import it here to avoid duplication: move the array from convex/lib/trustedOrigins.ts into packages/shared (export as LOCAL_TRUSTED_BROWSER_ORIGINS from e.g. authOrigins.ts), update convex/lib/trustedOrigins.ts to import and re-export or reference that exported symbol instead of redefining it, and update any other files (like packages/shared/src/authOrigins.ts) to consume the single exported constant so ports/origins are maintained in one place.apps/api/src/routes/publicV2/verification-intents.test.ts (1)
28-31: Add one assertion thatactorBindingis forwarded to the Convex action args.You already mock
actorBinding; asserting pass-through inobservedCalls[0]?.[1]would lock in the new auth contract and prevent silent regressions.✅ Example assertion addition
expect(observedCalls[0]?.[1]).toMatchObject({ apiSecret: 'test-secret', + actor: { + payload: 'test-payload', + signature: 'test-signature', + }, authUserId: 'user_abc', intentId: 'intent_123',🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/api/src/routes/publicV2/verification-intents.test.ts` around lines 28 - 31, Add an assertion in the verification-intents.test.ts that the mocked actorBinding object is forwarded as part of the Convex action args: after the test triggers the action, inspect observedCalls[0]?.[1] (the Convex action's args) and assert it includes the actorBinding payload and signature you set (e.g., { actorBinding: { payload: 'test-payload', signature: 'test-signature' } }). This ensures the test verifies the pass-through contract for the actorBinding when calling the Convex action.apps/api/src/routes/publicV2/webhooks.test.ts (1)
47-50: Consider assertingactorBindingpass-through in at least one webhooks route test.The mock now provides
actorBinding; adding a targeted assertion onqueryMock/mutationMockcall args would protect this new contract.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/api/src/routes/publicV2/webhooks.test.ts` around lines 47 - 50, Test coverage missing an assertion that the mocked gateway receives the actorBinding object; update at least one webhook route test that triggers queryMock or mutationMock to assert the mock was called with an args object containing actorBinding: { payload: 'test-payload', signature: 'test-signature' }. Locate the test that invokes queryMock or mutationMock in webhooks.test.ts and add an assertion on the call args (e.g., expect(queryMock).toHaveBeenCalledWith(expect.objectContaining({ actorBinding: { payload: 'test-payload', signature: 'test-signature' } })) or the equivalent for mutationMock) so the actorBinding pass-through contract is verified.convex/httpCertificates.contract.test.ts (1)
19-22: Avoid coupling this contract test to an implementation-localrawvariable.The behavior contract here is the generic 500 response and no raw error leak. Requiring
const raw = ...makes this test fail on harmless refactors.🧩 Suggested test tightening
it('hides raw issuance errors behind a generic 500 response', () => { - expect(httpSource).toContain("const raw = err instanceof Error ? err.message : ''"); expect(httpSource).toContain("return errorResponse('Certificate issuance failed', 500)"); expect(httpSource).not.toContain('return errorResponse(raw || String(err), 500)'); });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@convex/httpCertificates.contract.test.ts` around lines 19 - 22, The test is too tied to an implementation-local variable name `raw`; remove the assertion that expects "const raw = err instanceof Error ? err.message : ''" and instead assert only the observable contract: that the source contains the generic errorResponse("Certificate issuance failed", 500) and does not include any return that exposes the underlying error (e.g. strings containing "err.message", "String(err)" or interpolation of err). Update the test in the "hides raw issuance errors behind a generic 500 response" spec to drop the `raw` containment check and keep/expand the negative containment checks against any direct usage of err in errorResponse while still verifying the generic 500 response via httpSource and the existing literals.convex/backgroundSync.ts (1)
446-449: Consider extracting a shared helper for the background-sync service actor.The same actor-binding block is duplicated; a small helper would prevent future scope/service drift.
♻️ Suggested refactor
+async function getBackgroundSyncActor() { + return await createServiceActorBinding({ + service: 'background-sync', + scopes: ['creator:delegate'], + }); +} ... - const actor = await createServiceActorBinding({ - service: 'background-sync', - scopes: ['creator:delegate'], - }); + const actor = await getBackgroundSyncActor(); ... - const actor = await createServiceActorBinding({ - service: 'background-sync', - scopes: ['creator:delegate'], - }); + const actor = await getBackgroundSyncActor();Also applies to: 625-628
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@convex/backgroundSync.ts` around lines 446 - 449, Extract a small helper function (e.g., getBackgroundSyncActor or createBackgroundSyncActor) that encapsulates the createServiceActorBinding call with service: 'background-sync' and scopes: ['creator:delegate'], then replace the duplicated inline blocks that assign to actor with calls to this helper; update both occurrences (the one that assigns const actor = await createServiceActorBinding({...}) and the other duplicate) so they await the new helper instead of repeating the binding object.convex/yucpTrustBundle.contract.test.ts (1)
8-18: Prefer behavior-level contract assertions over source-string matching.These checks can pass/fail due to formatting or symbol renames without reflecting route behavior. Consider asserting
/v1/keysresponse shape and issuer semantics from the route handler directly.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@convex/yucpTrustBundle.contract.test.ts` around lines 8 - 18, Replace fragile source-string assertions in the test with behavior-level assertions that invoke the /v1/keys route handler (or the exported function that builds its response) and assert the response JSON shape: verify the response includes a trustBundle field and the legacy JWK set (i.e., presence of getConfiguredYucpJwkSet output), and that signing is delegated via signYucpTrustBundleJwt; additionally assert the issuer value equals the configured public API base URL by calling resolveConfiguredPublicApiBaseUrl() (and assert it is not requestUrl.origin). Locate references to httpSource, the test cases that mention '/v1/keys', signYucpTrustBundleJwt, getConfiguredYucpJwkSet, and resolveConfiguredPublicApiBaseUrl to replace string containment checks with direct handler invocation and JSON shape/semantic assertions.convex/lib/licenseSubjectLink.ts (1)
3-14: Consider removing unusedlicenseKeyandpurchaserEmailfrom input type.The
LicenseSubjectLinkInputtype still declareslicenseKeyandpurchaserEmailas optional fields, but they are never persisted in either the update or insert paths. While this maintains backward compatibility for existing callers, it may be misleading.Consider deprecating or removing these fields from the input type to signal that plaintext PII should not be passed.
Also applies to: 41-51
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@convex/lib/licenseSubjectLink.ts` around lines 3 - 14, The LicenseSubjectLinkInput type currently includes optional fields licenseKey and purchaserEmail which are not persisted; remove these two properties from the LicenseSubjectLinkInput type declaration (and any duplicate declaration around the other occurrence) or mark them explicitly deprecated with a clear comment/JSDoc if you need temporary backward compatibility, and update any related references or callers to stop passing plaintext PII (search for the symbol LicenseSubjectLinkInput and amend both declarations at the other occurrence noted). Ensure tests/types compile after removal and that insert/update code paths (e.g., functions handling license subject links) continue to only persist the intended fields.apps/bot/src/commands/downloads.ts (1)
625-631: Consider extracting actor binding to reduce duplicate awaits in sequential operations.In
handleDownloadsAutofixPromptandhandleDownloadsAutofixRun, the actor binding is fetched fresh each time. While functionally correct, if these functions are called in close succession for the same user flow, the binding could theoretically be passed through or cached at the handler level.However, given these are separate interaction handlers that may be invoked independently, the current approach is defensible for isolation.
Also applies to: 668-674
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/bot/src/commands/downloads.ts` around lines 625 - 631, The code repeatedly awaits getRequiredBotActorBinding inside handleDownloadsAutofixPrompt and handleDownloadsAutofixRun (and similar blocks at the 668-674 diff), causing duplicate awaits for the same actor; refactor by extracting actor retrieval out of the inner flows — either fetch once at the top-level of the command handler and pass the actor into handleDownloadsAutofixPrompt/handleDownloadsAutofixRun as a parameter, or introduce a short-lived cached variable (e.g., let actor = await getRequiredBotActorBinding()) used by both functions; update the calls and signatures for these handlers (handleDownloadsAutofixPrompt, handleDownloadsAutofixRun) accordingly to avoid re-awaiting the same binding.apps/api/src/routes/couplingLicenses.ts (1)
280-287: Consider consistent ordering: check pinned root before artifact resolution.In
issueCouplingJob, the pinned root check happens afterresolveRuntimeArtifact. For consistency withissueRuntimePackageToken(which checks pinned root early), consider moving this check before the artifact resolution to fail fast on configuration issues.♻️ Suggested reordering
if (!PROJECT_ID_RE.test(projectId)) { return errorResponse('Invalid projectId format', 400); } + try { + await getPinnedSigningRoot(); + } catch (error) { + return errorResponse( + error instanceof Error ? error.message : 'Pinned root trust is unavailable', + 503 + ); + } + const artifact = await resolveRuntimeArtifact(config, 'coupling-runtime'); if (!artifact.success) { return errorResponse(artifact.error ?? 'Coupling runtime is not configured on the server', 503); } - try { - await getPinnedSigningRoot(); - } catch (error) { - return errorResponse( - error instanceof Error ? error.message : 'Pinned root trust is unavailable', - 503 - ); - }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/api/src/routes/couplingLicenses.ts` around lines 280 - 287, In issueCouplingJob, move the pinned-root availability check (the try/catch calling getPinnedSigningRoot) to occur before any call to resolveRuntimeArtifact so the function fails fast on configuration issues; mirror the ordering used in issueRuntimePackageToken by relocating the existing try { await getPinnedSigningRoot(); } catch (...) block to precede the resolveRuntimeArtifact(...) invocation and return the same errorResponse on failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.agents/skills/impeccable/scripts/cleanup-deprecated.mjs:
- Around line 8-9: Update the usage comment so it's copy-pasteable by replacing
the templated `{{scripts_path}}` with the actual relative path to this script;
change the example line `node {{scripts_path}}/cleanup-deprecated.mjs` to `node
.agents/skills/impeccable/scripts/cleanup-deprecated.mjs` (edit the Usage
comment that surrounds that line in cleanup-deprecated.mjs).
- Around line 44-47: The root detection and CLI guard are POSIX-only; in
findProjectRoot replace the hard-coded `{ root: '/' }` usage with
path.parse(dir).root (ensure path.parse is imported) so root detection works on
Windows drives, and replace any new URL(import.meta.url).pathname comparisons
(used in the CLI guard around import.meta.url at/near line 200) with
fileURLToPath(import.meta.url) (ensure fileURLToPath is imported from 'url') so
path comparisons are platform-correct.
In `@apps/api/src/lib/httpSecurity.ts`:
- Around line 15-18: The current content-type check only matches literal
"application/json" so vendor or structured JSON types like
"application/problem+json" are missed; update the condition that computes
contentType and the if-statement (the contentType variable and the headers
check) to treat any media type that is either "application/json" or
ends/contains "+json" as JSON (e.g., change the if to check
contentType.includes('application/json') || contentType.includes('+json')) and
then set Cache-Control: no-store via headers.set when that condition is true and
Cache-Control is not already present.
In `@apps/bot/src/lib/convexActor.ts`:
- Around line 95-102: The predicate in shouldAttachActor currently returns true
even when callers explicitly provided args.actor, causing auto-attachment to
overwrite it; update shouldAttachActor (and keep using describeFunctionReference
and isApiActorProtectedFunction) to only auto-attach when the args object does
NOT contain an actor property (e.g., after verifying !!args && typeof args ===
'object' && !Array.isArray(args), add a check like !('actor' in (args as
Record<string, unknown>)) or use Object.prototype.hasOwnProperty.call to ensure
explicit actor bindings are preserved).
In `@convex/http.ts`:
- Around line 1257-1267: The code currently always verifies bearer certs with
signingRoot.publicKeyBase64 (from getPinnedSigningRoot), which breaks validation
for certs issued under previous roots; change the flow in the /v1/signatures
handling so you first parse/extract the cert envelope (e.g., get its kid)
without enforcing verification, then look up the correct public key from the
active trust bundle (or pinned roots) keyed by that kid, and finally call
parseBearerCert or the verification step with the selected public key; update
the logic that calls getPinnedSigningRoot and parseBearerCert so verification
uses the key matched to the cert's kid rather than the current root's
publicKeyBase64.
- Around line 210-225: getClientAddress is currently trusting client-controlled
headers (x-real-ip, x-forwarded-for) which allows IP spoofing; update
getClientAddress to only accept a platform/trusted-proxy supplied source (e.g.,
keep cf-connecting-ip) and do not use x-real-ip or x-forwarded-for unless you
first verify the request came through a trusted proxy (via config/allowlist or a
platform-provided flag). Replace the fallback logic in getClientAddress so it
returns cf-connecting-ip when present, otherwise uses a server-side/trusted
connection source (e.g., the real remote address supplied by the runtime) or
returns 'unknown' — remove unconditional parsing of x-real-ip and
x-forwarded-for to prevent attacker-controlled spoofing.
In `@convex/migrations.ts`:
- Around line 210-212: The code assigns licenseKeyEncrypted by falling back to
awaiting encryptPii(doc.licenseKey, PII_PURPOSES.forensicsLicenseKey) even when
doc.licenseKey may be undefined; update the assignment for licenseKeyEncrypted
(and references to doc.licenseKeyEncrypted and encryptPii) to guard the async
call so encryptPii is only invoked when doc.licenseKey is non-null/defined (e.g.
use a conditional that returns undefined when doc.licenseKey is falsy rather
than calling encryptPii).
In `@convex/testHelpers.ts`:
- Around line 108-127: mergeActorArg currently always overwrites args.actor
which hides tests that intentionally pass a custom/invalid actor; change
mergeActorArg to only add the actor when args is not an object (same existing
branch) or when args is an object but does not already have an "actor" property,
leaving an explicit actor intact; ensure shouldAttachActor logic (function
shouldAttachActor and call sites that use it) remains consistent with the new
behavior so that actor injection only happens when there's no explicit actor
present.
In `@convex/yucpCertificates.ts`:
- Around line 316-317: The code currently only reads process.env.YUCP_KEY_ID and
ignores the legacy process.env.YUCP_ROOT_KEY_ID, breaking backwards
compatibility; update the environment lookup (where YUCP_KEY_ID is read in
convex/yucpCertificates.ts — the variable or constant that holds the pinned key
id) to fallback to process.env.YUCP_ROOT_KEY_ID when YUCP_KEY_ID is undefined
(e.g., const yucpKeyId = process.env.YUCP_KEY_ID ??
process.env.YUCP_ROOT_KEY_ID), and ensure any places using that variable (or
function that returns the key id) continue to work unchanged; mirror the same
pattern used in convex/http.ts around the YUCP_* env handling.
In `@convex/yucpLicenses.realtest.ts`:
- Around line 19-41: The test mutates process.env keys and globalThis.fetch in
beforeEach but only resets pinned roots; capture originals (e.g., save
originalFetch and original values of YUCP_ROOT_PRIVATE_KEY,
YUCP_ROOT_PUBLIC_KEY, YUCP_ROOT_KEY_ID, POLAR_ACCESS_TOKEN,
POLAR_WEBHOOK_SECRET, YUCP_COUPLING_SERVICE_BASE_URL,
YUCP_COUPLING_SERVICE_SHARED_SECRET) and restore them in afterEach alongside
calling setPinnedYucpRootsForTests(null), ensuring globalThis.fetch and all
modified process.env entries are returned to their prior values (use the same
variable names like originalFetch and the YUCP_* env names to locate where to
store/restore).
In `@packages/shared/src/yucpTrust.ts`:
- Around line 139-163: The current try/catch in the trust-bundle parsing
silently falls back to getPinnedYucpRoots() on any parse/validation error;
change it to "fail closed" by rethrowing (or returning an explicit error) when
an explicit bundle was provided. In the catch block around JSON.parse(...) /
validation (the block that now returns {version:1, roots:
getPinnedYucpRoots()}), detect whether the caller provided a non-empty json
input (or process.env.YUCP_TRUST_BUNDLE_JSON is set) and if so throw a new Error
including the original error message; only fall back to getPinnedYucpRoots()
when no explicit bundle was supplied. Ensure the thrown error references the
parsing/validation failure so callers can surface the misconfiguration.
- Around line 46-52: The JWK `x` is being written and read using standard base64
instead of base64url, causing non-compliant OKP/Ed25519 JWKs and failed parsing;
update normalizeTrustJwk (which constructs YucpTrustJwk from YucpPinnedRoot) to
emit `x` encoded as base64url (RFC 8037), update the code path that reads
`record.x` to decode base64url when reconstructing keys, and stop silently
swallowing parse errors during bundle parsing (surface or propagate the error
instead of falling back silently to builtin roots) so invalid encodings are
detected; reference normalizeTrustJwk, YucpPinnedRoot, YucpTrustJwk and the
record.x parsing logic when making the changes.
---
Outside diff comments:
In `@apps/bot/src/commands/licenseVerify.ts`:
- Around line 577-609: The call to getRequiredBotActorBinding() can throw but is
executed outside the try-catch that handles ensureSubjectForDiscord, creating an
unhandled error path; move or duplicate getRequiredBotActorBinding() invocation
into the same try block (or wrap it in its own try) so any thrown error is
caught and handled the same way as errors from
convex.mutation(api.subjects.ensureSubjectForDiscord), and ensure you still
assign subjectId from ensureResult and run the same error branch (logging via
logger.error and sending the same interaction.editReply paths) when
getRequiredBotActorBinding() fails.
- Around line 461-481: getRequiredBotActorBinding() can throw before the
existing try-catch around convex.mutation, so wrap the actor retrieval in the
same error-handling path: move the call to getRequiredBotActorBinding() into the
try block (or add a short try/catch around it) so any errors are caught, logged
with logger.error (including the error and discordUserId), and the user is
notified via interaction.editReply with the same friendly failure message;
ensure you still call convex.mutation(api.subjects.ensureSubjectForDiscord, {
actor, ... }) only after actor is successfully obtained and preserve the
existing subjectId check and error handling.
In `@convex/downloads.ts`:
- Around line 154-175: After fetching guildLink with ctx.db.get and checking
ownership via requireOwnedDocument, also verify that the retrieved guildLink's
guildId matches args.guildId; if they differ, reject the request (throw an error
or return a 400/403) before calling ctx.db.insert to create the download route.
Update the logic around guildLink/guildLinkId validation in downloads.ts (the
code that calls requireOwnedDocument and then inserts into 'download_routes') to
perform this additional equality check and fail fast when guildId and
guildLink.guildId are inconsistent.
- Around line 311-333: The insert is using routing metadata from args which
allows inconsistent/malicious routing; change the
ctx.db.insert('download_artifacts', ...) call to source all route-derived fields
from the fetched route instead of args: use route.guildId,
route.sourceChannelId, route.sourceMessageId, route.sourceMessageUrl,
route.sourceAuthorId, route.archiveChannelId, route.archiveMessageId,
route.archiveThreadId, route.sourceRelayMessageId, route.sourceDeliveryMode and
use route.roleLogic and a de-duplicated route.requiredRoleIds (e.g. [...new
Set(route.requiredRoleIds || [])]); keep authUserId, files, status,
createdAt/updatedAt etc from args/context as appropriate and leave
requireOwnedDocument(route, args.authUserId) in place.
In `@convex/entitlements.ts`:
- Around line 1387-1401: Replace the delegated-creator authorization in this
scheduler mutation: remove the call to requireDelegatedAuthUserActor(args.actor,
args.authUserId) in the mutation handler and enforce a service-scoped actor
check instead (use or add a helper such as requireServiceActor(args.actor) or
requireSystemActor(args.actor) and call that before proceeding); keep the
requireApiSecret(args.apiSecret) check and continue using Date.now() as before,
and return the same payload shape (success, expiredCount, entitlementIds).
In `@convex/http.ts`:
- Around line 1362-1414: The protected-assets size check currently runs after
performing mutations (internal.packageRegistry.registerPackage and
internal.signingLog.writeEntry), risking partial commits; move the guard that
checks body.protectedAssets.length against MAX_PROTECTED_ASSETS_PER_REQUEST to
run before calling registerPackage or signingLog.writeEntry (i.e., at the top of
this handler), and return the same errorResponse(...) 400 immediately if
exceeded so no mutations are executed when the payload is oversized.
- Around line 476-507: In buildManifestCertificateChain, the issuerKeyId is
incorrectly taken from envelope.signature.keyId (rootKeyId) instead of the
actual signer’s key id; after calling
signPackageCertificateData(publisherCertificateBase, rootPrivateKey) capture the
signature metadata and set issuerKeyId on publisherCertificateBase (and the
returned publisher cert) to the signer's key id (e.g., the keyId from the
publisherSignature/result of signPackageCertificateData) so the chain records
the actual key that signed it (update buildManifestCertificateChain,
publisherCertificateBase.issuerKeyId, and the returned certificate object
accordingly).
In `@convex/yucpLicenses.ts`:
- Around line 1741-1760: The verification-only paths (e.g.,
redeemProtectedMaterializationGrant and issueCouplingJob) are calling
getPinnedSigningRoot(...) which currently requires the private root key; change
these to load only the pinned trust bundle/public roots so verification does not
depend on YUCP_ROOT_PRIVATE_KEY. Specifically, replace calls to
getPinnedSigningRoot(process.env.YUCP_ROOT_KEY_ID ?? null) in the shown block
(and the similar calls around lines 1973-1976) with a function or flag that
returns only the public trust bundle/pinned roots (e.g., getPinnedTrustBundle or
getPinnedSigningRoot({publicOnly:true})), and ensure downstream verification
functions (verifyProtectedUnlockJwtAgainstPinnedRoots) still receive the same
issuer/trust data but never trigger private-key loading or signing logic.
---
Nitpick comments:
In `@apps/api/src/routes/couplingLicenses.ts`:
- Around line 280-287: In issueCouplingJob, move the pinned-root availability
check (the try/catch calling getPinnedSigningRoot) to occur before any call to
resolveRuntimeArtifact so the function fails fast on configuration issues;
mirror the ordering used in issueRuntimePackageToken by relocating the existing
try { await getPinnedSigningRoot(); } catch (...) block to precede the
resolveRuntimeArtifact(...) invocation and return the same errorResponse on
failure.
In `@apps/api/src/routes/publicV2/verification-intents.test.ts`:
- Around line 28-31: Add an assertion in the verification-intents.test.ts that
the mocked actorBinding object is forwarded as part of the Convex action args:
after the test triggers the action, inspect observedCalls[0]?.[1] (the Convex
action's args) and assert it includes the actorBinding payload and signature you
set (e.g., { actorBinding: { payload: 'test-payload', signature:
'test-signature' } }). This ensures the test verifies the pass-through contract
for the actorBinding when calling the Convex action.
In `@apps/api/src/routes/publicV2/webhooks.test.ts`:
- Around line 47-50: Test coverage missing an assertion that the mocked gateway
receives the actorBinding object; update at least one webhook route test that
triggers queryMock or mutationMock to assert the mock was called with an args
object containing actorBinding: { payload: 'test-payload', signature:
'test-signature' }. Locate the test that invokes queryMock or mutationMock in
webhooks.test.ts and add an assertion on the call args (e.g.,
expect(queryMock).toHaveBeenCalledWith(expect.objectContaining({ actorBinding: {
payload: 'test-payload', signature: 'test-signature' } })) or the equivalent for
mutationMock) so the actorBinding pass-through contract is verified.
In `@apps/bot/src/commands/downloads.ts`:
- Around line 625-631: The code repeatedly awaits getRequiredBotActorBinding
inside handleDownloadsAutofixPrompt and handleDownloadsAutofixRun (and similar
blocks at the 668-674 diff), causing duplicate awaits for the same actor;
refactor by extracting actor retrieval out of the inner flows — either fetch
once at the top-level of the command handler and pass the actor into
handleDownloadsAutofixPrompt/handleDownloadsAutofixRun as a parameter, or
introduce a short-lived cached variable (e.g., let actor = await
getRequiredBotActorBinding()) used by both functions; update the calls and
signatures for these handlers (handleDownloadsAutofixPrompt,
handleDownloadsAutofixRun) accordingly to avoid re-awaiting the same binding.
In `@convex/backgroundSync.ts`:
- Around line 446-449: Extract a small helper function (e.g.,
getBackgroundSyncActor or createBackgroundSyncActor) that encapsulates the
createServiceActorBinding call with service: 'background-sync' and scopes:
['creator:delegate'], then replace the duplicated inline blocks that assign to
actor with calls to this helper; update both occurrences (the one that assigns
const actor = await createServiceActorBinding({...}) and the other duplicate) so
they await the new helper instead of repeating the binding object.
In `@convex/httpCertificates.contract.test.ts`:
- Around line 19-22: The test is too tied to an implementation-local variable
name `raw`; remove the assertion that expects "const raw = err instanceof Error
? err.message : ''" and instead assert only the observable contract: that the
source contains the generic errorResponse("Certificate issuance failed", 500)
and does not include any return that exposes the underlying error (e.g. strings
containing "err.message", "String(err)" or interpolation of err). Update the
test in the "hides raw issuance errors behind a generic 500 response" spec to
drop the `raw` containment check and keep/expand the negative containment checks
against any direct usage of err in errorResponse while still verifying the
generic 500 response via httpSource and the existing literals.
In `@convex/lib/licenseSubjectLink.ts`:
- Around line 3-14: The LicenseSubjectLinkInput type currently includes optional
fields licenseKey and purchaserEmail which are not persisted; remove these two
properties from the LicenseSubjectLinkInput type declaration (and any duplicate
declaration around the other occurrence) or mark them explicitly deprecated with
a clear comment/JSDoc if you need temporary backward compatibility, and update
any related references or callers to stop passing plaintext PII (search for the
symbol LicenseSubjectLinkInput and amend both declarations at the other
occurrence noted). Ensure tests/types compile after removal and that
insert/update code paths (e.g., functions handling license subject links)
continue to only persist the intended fields.
In `@convex/lib/trustedOrigins.ts`:
- Around line 6-19: Extract the LOCAL_TRUSTED_BROWSER_ORIGINS constant into the
shared package and import it here to avoid duplication: move the array from
convex/lib/trustedOrigins.ts into packages/shared (export as
LOCAL_TRUSTED_BROWSER_ORIGINS from e.g. authOrigins.ts), update
convex/lib/trustedOrigins.ts to import and re-export or reference that exported
symbol instead of redefining it, and update any other files (like
packages/shared/src/authOrigins.ts) to consume the single exported constant so
ports/origins are maintained in one place.
In `@convex/yucpTrustBundle.contract.test.ts`:
- Around line 8-18: Replace fragile source-string assertions in the test with
behavior-level assertions that invoke the /v1/keys route handler (or the
exported function that builds its response) and assert the response JSON shape:
verify the response includes a trustBundle field and the legacy JWK set (i.e.,
presence of getConfiguredYucpJwkSet output), and that signing is delegated via
signYucpTrustBundleJwt; additionally assert the issuer value equals the
configured public API base URL by calling resolveConfiguredPublicApiBaseUrl()
(and assert it is not requestUrl.origin). Locate references to httpSource, the
test cases that mention '/v1/keys', signYucpTrustBundleJwt,
getConfiguredYucpJwkSet, and resolveConfiguredPublicApiBaseUrl to replace string
containment checks with direct handler invocation and JSON shape/semantic
assertions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8cb6e2af-6eb1-4bee-873f-48a093982340
⛔ Files ignored due to path filters (1)
convex/_generated/api.d.tsis excluded by!**/_generated/**
📒 Files selected for processing (75)
.agents/skills/impeccable/scripts/cleanup-deprecated.mjsapps/api/src/createServer.tsapps/api/src/index.tsapps/api/src/lib/apiActor.tsapps/api/src/lib/convex.tsapps/api/src/lib/httpSecurity.tsapps/api/src/lib/yucpRuntimeCrypto.tsapps/api/src/routes/couplingLicenses.test.tsapps/api/src/routes/couplingLicenses.tsapps/api/src/routes/forensics.test.tsapps/api/src/routes/forensics.tsapps/api/src/routes/packages.tsapps/api/src/routes/publicV2/auth.tsapps/api/src/routes/publicV2/collaborators.test.tsapps/api/src/routes/publicV2/verification-intents.test.tsapps/api/src/routes/publicV2/webhooks.test.tsapps/api/test/index.test.tsapps/bot/src/commands/analytics.tsapps/bot/src/commands/downloads.tsapps/bot/src/commands/licenseVerify.tsapps/bot/src/commands/moderation.tsapps/bot/src/commands/stats.tsapps/bot/src/commands/status.tsapps/bot/src/commands/suspicious.tsapps/bot/src/commands/verify.tsapps/bot/src/index.tsapps/bot/src/lib/convexActor.tsapps/bot/src/lib/env.tsapps/bot/src/services/roleSync.tsconvex/authBinding.realtest.tsconvex/backgroundSync.tsconvex/couplingForensics.realtest.tsconvex/couplingForensics.tsconvex/downloads.tsconvex/entitlements.tsconvex/http.tsconvex/httpCertificates.contract.test.tsconvex/httpSurface.contract.test.tsconvex/lib/apiActor.tsconvex/lib/licenseSubjectLink.tsconvex/lib/ownership.tsconvex/lib/trustedOrigins.realtest.tsconvex/lib/trustedOrigins.tsconvex/lib/yucpCrypto.test.tsconvex/lib/yucpCrypto.tsconvex/lib/yucpTrustBundle.test.tsconvex/manualLicenses.realtest.tsconvex/manualLicenses.tsconvex/migrations.realtest.tsconvex/migrations.tsconvex/packageRegistry.realtest.tsconvex/packageRegistry.tsconvex/protectedAssetsBounds.realtest.tsconvex/protectedBlobArchitecture.realtest.tsconvex/protectedInstallIntent.realtest.tsconvex/protectedMaterializationGrant.realtest.tsconvex/protectedUnlock.realtest.tsconvex/subjects.tsconvex/testHelpers.tsconvex/tsconfig.tsbuildinfoconvex/verificationIntents.realtest.tsconvex/verificationIntents.tsconvex/verificationSessions.tsconvex/vitest.config.tsconvex/yucpCertificates.tsconvex/yucpLicenses.realtest.tsconvex/yucpLicenses.tsconvex/yucpTrustBundle.contract.test.tspackages/shared/src/apiActor.tspackages/shared/src/authOrigins.tspackages/shared/src/index.tspackages/shared/src/yucpTrust.test.tspackages/shared/src/yucpTrust.tspackages/shared/test/authOrigins.test.tsskills-lock.json
💤 Files with no reviewable changes (1)
- convex/couplingForensics.ts
Multiple fixes and improvements across HTTP/YUCP, actor handling, download routing, tests, and tooling: - HTTP/YUCP: verify cert envelopes against pinned roots, prefer YUCP_ROOT_KEY_ID over YUCP_KEY_ID, stop trusting caller-controlled forwarded IP headers, parseBearerCert no longer needs an explicit root key arg, and propagate signing root keyId into manifest certificate chains. Added earlier and stricter protectedAssets validation before registration/log writes. Added verifyCertEnvelopeAgainstPinnedRoots helper in yucpCrypto. - Response security: applyResponseSecurityHeaders now treats +json media types as JSON for cache-control and added a test. - Actor handling: test helpers and bot convexActor updated to avoid auto-attaching an actor when one is explicitly provided; added contract tests to enforce this behavior. - Downloads: enforce that a guild link's discordGuildId matches the provided guildId when creating routes; artifact creation now derives routing fields (guildId, source/archive channel, required roles, role logic) from the saved route. - Entitlements: use service-scoped actor requirement for grantEntitlementsForPurchaser (requireServiceActor). - Migrations & tests: avoid encrypting undefined license keys in migration, add/adjust realtests and unit tests (downloads, http contracts, convex actor contracts, protected materialization grant behaviors), and stabilize environment variable handling in tests to restore process.env after runs. - Misc: fix Impeccable cleanup script path detection by using fileURLToPath and proper root determination, and small testHelpers sync. These changes strengthen security checks, make actor injection explicit, enforce routing consistency, and add tests to lock in the intended behaviors.
| } | ||
|
|
||
| function toBase64Url(value: string): string { | ||
| return value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7f07a89432
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const cloudflareConnectingIp = request.headers.get('cf-connecting-ip')?.trim(); | ||
| if (cloudflareConnectingIp) { | ||
| return cloudflareConnectingIp; | ||
| } | ||
| return 'unknown'; |
There was a problem hiding this comment.
Preserve per-client isolation when Cloudflare IP is absent
When cf-connecting-ip is missing, this helper returns the literal 'unknown', so every unauthenticated caller shares the same rate-limit principal (unknown:anonymous). On non-Cloudflare environments (local/staging/self-hosted, or any proxy that doesn't inject this header), one noisy client can trigger 429s for unrelated users on shared routes like runtime downloads and proxy endpoints. Add a trusted fallback source (or disable IP-based limiting when no trusted address is available) to avoid global throttling.
Useful? React with 👍 / 👎.
| const PROXY_REQUEST_HEADER_ALLOWLIST = new Set([ | ||
| 'accept', | ||
| 'accept-encoding', | ||
| 'authorization', | ||
| 'baggage', | ||
| 'cache-control', | ||
| 'content-length', | ||
| 'content-type', | ||
| 'idempotency-key', | ||
| 'traceparent', | ||
| 'tracestate', | ||
| 'x-request-id', | ||
| ]); |
There was a problem hiding this comment.
Forward Range headers for proxied runtime downloads
The new request-header allowlist drops range (and related conditional range headers), but /v1/licenses/runtime-package and /v1/licenses/coupling-runtime are proxied download endpoints. Clients that rely on resumable/partial downloads will no longer get byte-range behavior through this proxy and may be forced into full re-downloads or fail resume flows. Include range request headers in the allowlist for these download paths.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 14
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
convex/http.ts (1)
1743-1748:⚠️ Potential issue | 🟠 MajorResolve the signing root before consuming the one-time nonce.
Line 1744 spends the nonce, but Line 1747 can still throw on root misconfiguration. That turns a transient server-side failure into a permanent client retry failure.
Suggested fix
- // c64: Consume nonce before issuing JWT, prevents replay of identical requests. - await ctx.runMutation(internal.yucpLicenses.checkAndConsumeNonce, { nonce }); - - // Issue machine-fingerprint-bound license JWT - const signingRoot = await getPinnedSigningRoot(process.env.YUCP_ROOT_KEY_ID ?? null); + // Resolve signing prerequisites before consuming the one-time nonce. + let signingRoot: Awaited<ReturnType<typeof getPinnedSigningRoot>>; + try { + signingRoot = await getPinnedSigningRoot( + process.env.YUCP_ROOT_KEY_ID ?? process.env.YUCP_KEY_ID ?? null + ); + } catch { + return errorResponse('Service not configured', 503); + } + + // Consume nonce only once the request is ready to be signed. + await ctx.runMutation(internal.yucpLicenses.checkAndConsumeNonce, { nonce });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@convex/http.ts` around lines 1743 - 1748, The nonce is being consumed before resolving the signing root which can convert transient signing-root errors into permanent client failures; call getPinnedSigningRoot(process.env.YUCP_ROOT_KEY_ID ?? null) and await it first to ensure the signing root is valid, then invoke ctx.runMutation(internal.yucpLicenses.checkAndConsumeNonce, { nonce }) to consume the nonce and proceed to issue the JWT (update the order around getPinnedSigningRoot and checkAndConsumeNonce).
🧹 Nitpick comments (1)
convex/downloads.ts (1)
283-299: Trim the ignored route-derived fields fromcreateArtifact's public args.This handler now persists
guildId,sourceChannelId,archiveChannelId,requiredRoleIds, androleLogicfrom the saved route, but the mutation still requires callers to send all of them. That makes bad input look accepted. Either remove those fields from the contract or reject mismatches explicitly.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@convex/downloads.ts` around lines 283 - 299, The public argument schema for the createArtifact mutation still requires route-derived fields (guildId, sourceChannelId, archiveChannelId, requiredRoleIds, roleLogic) even though createArtifact persists those from the saved route; update the handler by removing these fields from the public args contract (the argument validator/schema used by createArtifact in convex/downloads.ts) so callers don't need to supply them, or alternatively add explicit validation inside createArtifact to reject requests where any of those caller-supplied values conflict with the saved route values (compare incoming guildId/sourceChannelId/archiveChannelId/requiredRoleIds/roleLogic against the route and throw on mismatch).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.agents/skills/impeccable/scripts/cleanup-deprecated.mjs:
- Around line 48-60: The loop stops when dir === root without checking root for
repo markers, so modify the exit logic to check the root directory once before
falling back: after the while loop (which currently checks existsSync(join(dir,
...)) for intermediate dirs), add a check using existsSync(join(root,
'package.json')), existsSync(join(root, '.git')), or existsSync(join(root,
'skills-lock.json')) and return root if any exist; otherwise continue to return
resolve(startDir). Reference symbols: dir, root, startDir, existsSync, join,
resolve.
In `@apps/bot/src/lib/convexActor.ts`:
- Around line 20-73: The cachedDefaultBotActor is only time-keyed so rotating
INTERNAL_SERVICE_AUTH_SECRET can leave callers using an old binding; modify
getDefaultBotActorBinding to include the current secret in the cache key (e.g.,
store secret or its hash alongside cachedDefaultBotActor) and
invalidate/recreate the binding when process.env.INTERNAL_SERVICE_AUTH_SECRET
changes before returning the cached binding; update cachedDefaultBotActor to
hold the secret value and check it against the current secret prior to returning
the cached binding (still respect the existing expiresAt logic and reuse
createServiceApiActor/createApiActorBinding when recreating).
In `@convex/downloads.ts`:
- Around line 154-160: Assign the return value of requireOwnedDocument to the
local variables so TypeScript knows they are non-null: replace the discarded
call with e.g. guildLink = requireOwnedDocument(guildLink, args.authUserId,
'Unauthorized: caller does not own this guild link') in the downloads handler,
and do the same in the createArtifact handler for route (route =
requireOwnedDocument(route, args.authUserId, '...')). This ensures guildLink and
route are narrowed non-nullable before you access their properties.
- Around line 314-331: The code calls requireOwnedDocument(route,
args.authUserId) but ignores its return, leaving the local variable route
nullable and causing TS errors when accessing its properties in the insert call;
fix by using the narrowed/non-null return from requireOwnedDocument (e.g., const
ownedRoute = requireOwnedDocument(route, args.authUserId) or reassign route =
requireOwnedDocument(route, args.authUserId)) and then reference ownedRoute (or
the reassigned route) for properties used in the ctx.db.insert into
'download_artifacts' so TypeScript recognizes the object as non-null (affecting
uses of route.guildId, route.sourceChannelId, route.sourceMessageId,
route.sourceMessageUrl, route.sourceAuthorId, route.archiveChannelId,
route.archiveMessageId, route.archiveThreadId, route.sourceRelayMessageId,
route.sourceDeliveryMode, route.requiredRoleIds, route.roleLogic).
In `@convex/http.ts`:
- Around line 235-237: The rate-limit key construction uses the
attacker-controlled options.identity as the primary identifier (in principal and
key), allowing callers to evade limits; change the key so the stable caller
identifier (e.g., getClientAddress(request) and any authenticated subject, not
options.identity alone) is the main bucket and include resource IDs only as a
secondary dimension. Concretely, update the logic around principal and key (see
principal, key, getClientAddress, sha256HexHttp, and options.identity) to build
key = `${scope}:${await sha256HexHttp(stableCallerId)}` (where stableCallerId is
getClientAddress(request) plus any verified auth subject) and, if needed, append
a resource-specific suffix instead of using options.identity as the primary
hashed value.
In `@convex/protectedMaterializationGrant.realtest.ts`:
- Around line 28-35: The test fixture must isolate YUCP_TRUST_BUNDLE_JSON so
setPinnedYucpRootsForTests() runs deterministically; update the ENV_KEYS
constant or the fixture setup to include 'YUCP_TRUST_BUNDLE_JSON' (alongside
YUCP_ROOT_PRIVATE_KEY, YUCP_ROOT_PUBLIC_KEY, etc.) so the env is controlled, or
explicitly delete process.env.YUCP_TRUST_BUNDLE_JSON before calling
setPinnedYucpRootsForTests(); locate the ENV_KEYS declaration and the test setup
that invokes setPinnedYucpRootsForTests() and apply one of these changes to
ensure the pinned root configured in the test cannot be overridden by an
external environment variable.
In `@convex/testHelpers.ts`:
- Around line 48-105: The cache reuse in getTestActorBinding can return a
binding signed with a different INTERNAL_SERVICE_AUTH_SECRET; update the
cachedTestActor structure to include the secret used (e.g., add a secret: string
field) and change the cache check to ensure cachedTestActor.expiresAt > now +
30_000 AND cachedTestActor.secret === secret before returning
cachedTestActor.binding; if the secret differs, create a new actor/binding via
createServiceApiActor and createApiActorBinding, store the new secret on
cachedTestActor along with binding and expiresAt, and then return the new
binding.
In `@convex/yucpCertificates.contract.test.ts`:
- Around line 7-10: The contract test "supports both legacy and current root key
id env names" currently expects YUCP_KEY_ID to take precedence; update the
assertion so the new root-key precedence is enforced by expecting
process.env.YUCP_ROOT_KEY_ID ?? process.env.YUCP_KEY_ID ?? null instead (so
YUCP_ROOT_KEY_ID wins when both are present), matching the precedence used in
convex/http.contract.test.ts and ensuring both surfaces select the same root.
In `@convex/yucpLicenses.realtest.ts`:
- Around line 15-23: The tests currently enumerate ENV_KEYS but omit
YUCP_TRUST_BUNDLE_JSON, so the verifier may pick up an existing
process.env.YUCP_TRUST_BUNDLE_JSON; update the ENV_KEYS constant to include
'YUCP_TRUST_BUNDLE_JSON' and ensure the test setup/teardown that iterates
ENV_KEYS (used to clear and restore env vars) also clears/restores this
variable—locate the ENV_KEYS array and the corresponding setup/teardown code in
yucpLicenses.realtest.ts and add the new key so the bundle is always reset for
these tests.
In `@convex/yucpLicenses.ts`:
- Line 1232: The call to getPinnedSigningRoot is only using
process.env.YUCP_ROOT_KEY_ID, dropping the legacy YUCP_KEY_ID fallback; update
each call to pass process.env.YUCP_ROOT_KEY_ID ?? process.env.YUCP_KEY_ID ??
null so the legacy env var is honored (e.g., update the occurrences that call
getPinnedSigningRoot and any similar signing-path calls to use this combined
fallback).
- Around line 1036-1049: The current fallback always calls encryptPii which can
be passed undefined (in the licenseKeyEncrypted assignment), so change the logic
for licenseKeyEncrypted to: use args.licenseKeyEncrypted if present, else if
args.licenseKey is present call encryptPii(args.licenseKey,
PII_PURPOSES.forensicsLicenseKey), otherwise leave licenseKeyEncrypted
undefined/null; update the assignment that computes licenseKeyEncrypted and
ensure upsertLicenseSubjectLink is passed that possibly-undefined value (do not
call encryptPii with undefined). This affects the licenseKeyEncrypted assignment
and the encryptPii call in the surrounding block.
In `@packages/shared/src/apiActor.test.ts`:
- Line 21: Reformat the failing test assertion so the expectation uses the
promise-resolves style in one properly spaced expression: call
verifyApiActorBinding(binding, 'test-secret', now + 1_000) inside await
expect(...).resolves.toEqual(actor) (ensure the parentheses, spacing and
semicolon match the shared formatter's style for the expression and trailing
semicolon).
In `@packages/shared/src/yucpTrust.ts`:
- Around line 164-169: The current mapping silently drops invalid entries by
filtering out nulls from the parseTrustedRootCandidate results; change this to
fail-fast by detecting any nulls and throwing instead. Update the code around
the keys.map(... parseTrustedRootCandidate ...) so you collect the raw mapped
results, check for any item === null (optionally recording the index or original
entry), and if any are present throw a descriptive Error like 'invalid trust
bundle: contains invalid entries' (or include offending entries); only when all
entries are non-null convert/cast the array to YucpTrustedRoot[] and proceed.
---
Outside diff comments:
In `@convex/http.ts`:
- Around line 1743-1748: The nonce is being consumed before resolving the
signing root which can convert transient signing-root errors into permanent
client failures; call getPinnedSigningRoot(process.env.YUCP_ROOT_KEY_ID ?? null)
and await it first to ensure the signing root is valid, then invoke
ctx.runMutation(internal.yucpLicenses.checkAndConsumeNonce, { nonce }) to
consume the nonce and proceed to issue the JWT (update the order around
getPinnedSigningRoot and checkAndConsumeNonce).
---
Nitpick comments:
In `@convex/downloads.ts`:
- Around line 283-299: The public argument schema for the createArtifact
mutation still requires route-derived fields (guildId, sourceChannelId,
archiveChannelId, requiredRoleIds, roleLogic) even though createArtifact
persists those from the saved route; update the handler by removing these fields
from the public args contract (the argument validator/schema used by
createArtifact in convex/downloads.ts) so callers don't need to supply them, or
alternatively add explicit validation inside createArtifact to reject requests
where any of those caller-supplied values conflict with the saved route values
(compare incoming
guildId/sourceChannelId/archiveChannelId/requiredRoleIds/roleLogic against the
route and throw on mismatch).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 32b806c3-9224-4c93-a886-e291018b5cca
📒 Files selected for processing (24)
.agents/skills/impeccable/scripts/cleanup-deprecated.mjsapps/api/src/lib/httpSecurity.test.tsapps/api/src/lib/httpSecurity.tsapps/bot/src/lib/convexActor.contract.test.tsapps/bot/src/lib/convexActor.tsconvex/authBinding.realtest.tsconvex/downloads.realtest.tsconvex/downloads.tsconvex/entitlements.tsconvex/http.contract.test.tsconvex/http.tsconvex/lib/yucpCrypto.tsconvex/migrations.tsconvex/protectedMaterializationGrant.realtest.tsconvex/testHelpers.tsconvex/tsconfig.tsbuildinfoconvex/yucpCertificates.contract.test.tsconvex/yucpCertificates.tsconvex/yucpLicenses.realtest.tsconvex/yucpLicenses.tspackages/shared/src/apiActor.test.tspackages/shared/src/apiActor.tspackages/shared/src/yucpTrust.test.tspackages/shared/src/yucpTrust.ts
✅ Files skipped from review due to trivial changes (1)
- apps/bot/src/lib/convexActor.contract.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
- apps/api/src/lib/httpSecurity.ts
- convex/yucpCertificates.ts
- convex/migrations.ts
- packages/shared/src/yucpTrust.test.ts
- convex/authBinding.realtest.ts
| while (dir !== root) { | ||
| if ( | ||
| existsSync(join(dir, 'package.json')) || | ||
| existsSync(join(dir, '.git')) || | ||
| existsSync(join(dir, 'skills-lock.json')) | ||
| ) { | ||
| return dir; | ||
| } | ||
| const parent = resolve(dir, '..'); | ||
| if (parent === dir) break; | ||
| dir = parent; | ||
| } | ||
| return resolve(startDir); |
There was a problem hiding this comment.
Check the filesystem root once before fallback.
The loop exits when dir === root without checking root markers, so a repo rooted at the filesystem root is missed.
Suggested fix
export function findProjectRoot(startDir = process.cwd()) {
let dir = resolve(startDir);
const { root } = parse(dir);
- while (dir !== root) {
+ while (true) {
if (
existsSync(join(dir, 'package.json')) ||
existsSync(join(dir, '.git')) ||
existsSync(join(dir, 'skills-lock.json'))
) {
return dir;
}
- const parent = resolve(dir, '..');
- if (parent === dir) break;
- dir = parent;
+ if (dir === root) break;
+ dir = resolve(dir, '..');
}
return resolve(startDir);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| while (dir !== root) { | |
| if ( | |
| existsSync(join(dir, 'package.json')) || | |
| existsSync(join(dir, '.git')) || | |
| existsSync(join(dir, 'skills-lock.json')) | |
| ) { | |
| return dir; | |
| } | |
| const parent = resolve(dir, '..'); | |
| if (parent === dir) break; | |
| dir = parent; | |
| } | |
| return resolve(startDir); | |
| export function findProjectRoot(startDir = process.cwd()) { | |
| let dir = resolve(startDir); | |
| const { root } = parse(dir); | |
| while (true) { | |
| if ( | |
| existsSync(join(dir, 'package.json')) || | |
| existsSync(join(dir, '.git')) || | |
| existsSync(join(dir, 'skills-lock.json')) | |
| ) { | |
| return dir; | |
| } | |
| if (dir === root) break; | |
| dir = resolve(dir, '..'); | |
| } | |
| return resolve(startDir); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.agents/skills/impeccable/scripts/cleanup-deprecated.mjs around lines 48 -
60, The loop stops when dir === root without checking root for repo markers, so
modify the exit logic to check the root directory once before falling back:
after the while loop (which currently checks existsSync(join(dir, ...)) for
intermediate dirs), add a check using existsSync(join(root, 'package.json')),
existsSync(join(root, '.git')), or existsSync(join(root, 'skills-lock.json'))
and return root if any exist; otherwise continue to return resolve(startDir).
Reference symbols: dir, root, startDir, existsSync, join, resolve.
| if (stat.isSymbolicLink()) { | ||
| // Symlink: check the target if it's alive, otherwise treat | ||
| // dangling symlinks to deprecated names as safe to remove. | ||
| const targetAlive = existsSync(skillPath); | ||
| const isMatch = targetAlive ? isImpeccableSkill(skillPath) : true; | ||
| if (isMatch) { | ||
| unlinkSync(skillPath); | ||
| deleted.push(skillPath); | ||
| } |
There was a problem hiding this comment.
Avoid deleting dangling symlinks by name alone.
Line 133 currently treats every dangling symlink as a match (... : true), which can delete unrelated user skills that happen to use deprecated/generic names.
Suggested fix
if (stat.isSymbolicLink()) {
- // Symlink: check the target if it's alive, otherwise treat
- // dangling symlinks to deprecated names as safe to remove.
- const targetAlive = existsSync(skillPath);
- const isMatch = targetAlive ? isImpeccableSkill(skillPath) : true;
- if (isMatch) {
+ // Only remove symlinks we can positively identify as Impeccable.
+ // Skip dangling links to avoid deleting unrelated user artifacts.
+ if (!existsSync(skillPath)) {
+ continue;
+ }
+ if (isImpeccableSkill(skillPath)) {
unlinkSync(skillPath);
deleted.push(skillPath);
}
continue;
}| let cachedDefaultBotActor: { | ||
| binding: ApiActorBinding; | ||
| expiresAt: number; | ||
| } | null = null; | ||
|
|
||
| function describeFunctionReference(functionReference: unknown): string { | ||
| try { | ||
| return getFunctionName(functionReference as never); | ||
| } catch { | ||
| // Fall through to ad hoc inspection for simple string mocks. | ||
| } | ||
| if (typeof functionReference === 'string') { | ||
| return functionReference; | ||
| } | ||
| if (!functionReference || typeof functionReference !== 'object') { | ||
| return 'unknown'; | ||
| } | ||
|
|
||
| const candidate = functionReference as { | ||
| name?: unknown; | ||
| _name?: unknown; | ||
| functionName?: unknown; | ||
| canonicalReference?: unknown; | ||
| }; | ||
|
|
||
| if (typeof candidate.name === 'string') return candidate.name; | ||
| if (typeof candidate._name === 'string') return candidate._name; | ||
| if (typeof candidate.functionName === 'string') return candidate.functionName; | ||
| if (typeof candidate.canonicalReference === 'string') return candidate.canonicalReference; | ||
| return 'unknown'; | ||
| } | ||
|
|
||
| async function getDefaultBotActorBinding(): Promise<ApiActorBinding | undefined> { | ||
| const secret = process.env.INTERNAL_SERVICE_AUTH_SECRET?.trim(); | ||
| if (!secret) { | ||
| return undefined; | ||
| } | ||
|
|
||
| const now = Date.now(); | ||
| if (cachedDefaultBotActor && cachedDefaultBotActor.expiresAt > now + 30_000) { | ||
| return cachedDefaultBotActor.binding; | ||
| } | ||
|
|
||
| const actor = createServiceApiActor({ | ||
| service: 'discord-bot', | ||
| scopes: DEFAULT_BOT_SERVICE_SCOPES, | ||
| now, | ||
| }); | ||
| const binding = await createApiActorBinding(actor, secret); | ||
| cachedDefaultBotActor = { | ||
| binding, | ||
| expiresAt: actor.expiresAt, | ||
| }; | ||
| return binding; |
There was a problem hiding this comment.
Invalidate the cached bot actor when the signing secret changes.
The cache is keyed only by time. If INTERNAL_SERVICE_AUTH_SECRET rotates, protected Convex calls will keep sending a binding signed with the old secret until this entry expires.
Suggested fix
let cachedDefaultBotActor: {
binding: ApiActorBinding;
expiresAt: number;
+ secret: string;
} | null = null;
@@
- if (cachedDefaultBotActor && cachedDefaultBotActor.expiresAt > now + 30_000) {
+ if (
+ cachedDefaultBotActor &&
+ cachedDefaultBotActor.secret === secret &&
+ cachedDefaultBotActor.expiresAt > now + 30_000
+ ) {
return cachedDefaultBotActor.binding;
}
@@
cachedDefaultBotActor = {
binding,
expiresAt: actor.expiresAt,
+ secret,
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let cachedDefaultBotActor: { | |
| binding: ApiActorBinding; | |
| expiresAt: number; | |
| } | null = null; | |
| function describeFunctionReference(functionReference: unknown): string { | |
| try { | |
| return getFunctionName(functionReference as never); | |
| } catch { | |
| // Fall through to ad hoc inspection for simple string mocks. | |
| } | |
| if (typeof functionReference === 'string') { | |
| return functionReference; | |
| } | |
| if (!functionReference || typeof functionReference !== 'object') { | |
| return 'unknown'; | |
| } | |
| const candidate = functionReference as { | |
| name?: unknown; | |
| _name?: unknown; | |
| functionName?: unknown; | |
| canonicalReference?: unknown; | |
| }; | |
| if (typeof candidate.name === 'string') return candidate.name; | |
| if (typeof candidate._name === 'string') return candidate._name; | |
| if (typeof candidate.functionName === 'string') return candidate.functionName; | |
| if (typeof candidate.canonicalReference === 'string') return candidate.canonicalReference; | |
| return 'unknown'; | |
| } | |
| async function getDefaultBotActorBinding(): Promise<ApiActorBinding | undefined> { | |
| const secret = process.env.INTERNAL_SERVICE_AUTH_SECRET?.trim(); | |
| if (!secret) { | |
| return undefined; | |
| } | |
| const now = Date.now(); | |
| if (cachedDefaultBotActor && cachedDefaultBotActor.expiresAt > now + 30_000) { | |
| return cachedDefaultBotActor.binding; | |
| } | |
| const actor = createServiceApiActor({ | |
| service: 'discord-bot', | |
| scopes: DEFAULT_BOT_SERVICE_SCOPES, | |
| now, | |
| }); | |
| const binding = await createApiActorBinding(actor, secret); | |
| cachedDefaultBotActor = { | |
| binding, | |
| expiresAt: actor.expiresAt, | |
| }; | |
| return binding; | |
| let cachedDefaultBotActor: { | |
| binding: ApiActorBinding; | |
| expiresAt: number; | |
| secret: string; | |
| } | null = null; | |
| function describeFunctionReference(functionReference: unknown): string { | |
| try { | |
| return getFunctionName(functionReference as never); | |
| } catch { | |
| // Fall through to ad hoc inspection for simple string mocks. | |
| } | |
| if (typeof functionReference === 'string') { | |
| return functionReference; | |
| } | |
| if (!functionReference || typeof functionReference !== 'object') { | |
| return 'unknown'; | |
| } | |
| const candidate = functionReference as { | |
| name?: unknown; | |
| _name?: unknown; | |
| functionName?: unknown; | |
| canonicalReference?: unknown; | |
| }; | |
| if (typeof candidate.name === 'string') return candidate.name; | |
| if (typeof candidate._name === 'string') return candidate._name; | |
| if (typeof candidate.functionName === 'string') return candidate.functionName; | |
| if (typeof candidate.canonicalReference === 'string') return candidate.canonicalReference; | |
| return 'unknown'; | |
| } | |
| async function getDefaultBotActorBinding(): Promise<ApiActorBinding | undefined> { | |
| const secret = process.env.INTERNAL_SERVICE_AUTH_SECRET?.trim(); | |
| if (!secret) { | |
| return undefined; | |
| } | |
| const now = Date.now(); | |
| if ( | |
| cachedDefaultBotActor && | |
| cachedDefaultBotActor.secret === secret && | |
| cachedDefaultBotActor.expiresAt > now + 30_000 | |
| ) { | |
| return cachedDefaultBotActor.binding; | |
| } | |
| const actor = createServiceApiActor({ | |
| service: 'discord-bot', | |
| scopes: DEFAULT_BOT_SERVICE_SCOPES, | |
| now, | |
| }); | |
| const binding = await createApiActorBinding(actor, secret); | |
| cachedDefaultBotActor = { | |
| binding, | |
| expiresAt: actor.expiresAt, | |
| secret, | |
| }; | |
| return binding; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/bot/src/lib/convexActor.ts` around lines 20 - 73, The
cachedDefaultBotActor is only time-keyed so rotating
INTERNAL_SERVICE_AUTH_SECRET can leave callers using an old binding; modify
getDefaultBotActorBinding to include the current secret in the cache key (e.g.,
store secret or its hash alongside cachedDefaultBotActor) and
invalidate/recreate the binding when process.env.INTERNAL_SERVICE_AUTH_SECRET
changes before returning the cached binding; update cachedDefaultBotActor to
hold the secret value and check it against the current secret prior to returning
the cached binding (still respect the existing expiresAt logic and reuse
createServiceApiActor/createApiActorBinding when recreating).
| const ENV_KEYS = [ | ||
| 'YUCP_ROOT_PRIVATE_KEY', | ||
| 'YUCP_ROOT_PUBLIC_KEY', | ||
| 'YUCP_ROOT_KEY_ID', | ||
| 'POLAR_ACCESS_TOKEN', | ||
| 'POLAR_WEBHOOK_SECRET', | ||
| 'YUCP_COUPLING_SERVICE_BASE_URL', | ||
| 'YUCP_COUPLING_SERVICE_SHARED_SECRET', | ||
| ] as const; |
There was a problem hiding this comment.
Clear YUCP_TRUST_BUNDLE_JSON here as well.
These tests pin a root, but the verifier now prefers process.env.YUCP_TRUST_BUNDLE_JSON when present. Without clearing/restoring that env var, this suite can accidentally verify against some other trust bundle.
Suggested fix
const ENV_KEYS = [
'YUCP_ROOT_PRIVATE_KEY',
'YUCP_ROOT_PUBLIC_KEY',
'YUCP_ROOT_KEY_ID',
+ 'YUCP_TRUST_BUNDLE_JSON',
'POLAR_ACCESS_TOKEN',
'POLAR_WEBHOOK_SECRET',
'YUCP_COUPLING_SERVICE_BASE_URL',
'YUCP_COUPLING_SERVICE_SHARED_SECRET',
] as const;
@@
beforeEach(async () => {
+ delete process.env.YUCP_TRUST_BUNDLE_JSON;
rootPrivateKey = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64');Also applies to: 27-30, 32-62
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@convex/yucpLicenses.realtest.ts` around lines 15 - 23, The tests currently
enumerate ENV_KEYS but omit YUCP_TRUST_BUNDLE_JSON, so the verifier may pick up
an existing process.env.YUCP_TRUST_BUNDLE_JSON; update the ENV_KEYS constant to
include 'YUCP_TRUST_BUNDLE_JSON' and ensure the test setup/teardown that
iterates ENV_KEYS (used to clear and restore env vars) also clears/restores this
variable—locate the ENV_KEYS array and the corresponding setup/teardown code in
yucpLicenses.realtest.ts and add the new key so the bundle is always reset for
these tests.
| const licenseKeyEncrypted = | ||
| args.licenseKeyEncrypted ?? | ||
| (await encryptPii(args.licenseKey, PII_PURPOSES.forensicsLicenseKey)); | ||
|
|
||
| await upsertLicenseSubjectLink(ctx, { | ||
| authUserId: args.authUserId, | ||
| licenseSubject: args.licenseSubject, | ||
| packageId: args.packageId, | ||
| provider: args.provider, | ||
| licenseKeyEncrypted, | ||
| providerUserId: args.providerUserId, | ||
| externalOrderId: args.externalOrderId, | ||
| providerProductId: args.providerProductId, | ||
| }); |
There was a problem hiding this comment.
Guard the fallback encryption when no raw license key is available.
licenseKey is optional here, so this path still calls encryptPii(undefined, ...) when callers provide neither field. That reintroduces the same “encrypt undefined” failure mode this PR is fixing elsewhere.
Suggested fix
const licenseKeyEncrypted =
args.licenseKeyEncrypted ??
- (await encryptPii(args.licenseKey, PII_PURPOSES.forensicsLicenseKey));
+ (args.licenseKey
+ ? await encryptPii(args.licenseKey, PII_PURPOSES.forensicsLicenseKey)
+ : undefined);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const licenseKeyEncrypted = | |
| args.licenseKeyEncrypted ?? | |
| (await encryptPii(args.licenseKey, PII_PURPOSES.forensicsLicenseKey)); | |
| await upsertLicenseSubjectLink(ctx, { | |
| authUserId: args.authUserId, | |
| licenseSubject: args.licenseSubject, | |
| packageId: args.packageId, | |
| provider: args.provider, | |
| licenseKeyEncrypted, | |
| providerUserId: args.providerUserId, | |
| externalOrderId: args.externalOrderId, | |
| providerProductId: args.providerProductId, | |
| }); | |
| const licenseKeyEncrypted = | |
| args.licenseKeyEncrypted ?? | |
| (args.licenseKey | |
| ? await encryptPii(args.licenseKey, PII_PURPOSES.forensicsLicenseKey) | |
| : undefined); | |
| await upsertLicenseSubjectLink(ctx, { | |
| authUserId: args.authUserId, | |
| licenseSubject: args.licenseSubject, | |
| packageId: args.packageId, | |
| provider: args.provider, | |
| licenseKeyEncrypted, | |
| providerUserId: args.providerUserId, | |
| externalOrderId: args.externalOrderId, | |
| providerProductId: args.providerProductId, | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@convex/yucpLicenses.ts` around lines 1036 - 1049, The current fallback always
calls encryptPii which can be passed undefined (in the licenseKeyEncrypted
assignment), so change the logic for licenseKeyEncrypted to: use
args.licenseKeyEncrypted if present, else if args.licenseKey is present call
encryptPii(args.licenseKey, PII_PURPOSES.forensicsLicenseKey), otherwise leave
licenseKeyEncrypted undefined/null; update the assignment that computes
licenseKeyEncrypted and ensure upsertLicenseSubjectLink is passed that
possibly-undefined value (do not call encryptPii with undefined). This affects
the licenseKeyEncrypted assignment and the encryptPii call in the surrounding
block.
| // 5. Issue signed license JWT | ||
| const rootPrivateKey = process.env.YUCP_ROOT_PRIVATE_KEY; | ||
| if (!rootPrivateKey) throw new Error('YUCP_ROOT_PRIVATE_KEY not configured'); | ||
| const signingRoot = await getPinnedSigningRoot(process.env.YUCP_ROOT_KEY_ID ?? null); |
There was a problem hiding this comment.
Keep the legacy YUCP_KEY_ID fallback in these signing paths.
These calls only pass YUCP_ROOT_KEY_ID, while other updated entrypoints still fall back to YUCP_KEY_ID. In deployments that still use the legacy variable, issued JWTs will select the first matching root instead of the configured one.
Suggested fix
- const signingRoot = await getPinnedSigningRoot(process.env.YUCP_ROOT_KEY_ID ?? null);
+ const signingRoot = await getPinnedSigningRoot(
+ process.env.YUCP_ROOT_KEY_ID ?? process.env.YUCP_KEY_ID ?? null
+ );Also applies to: 1324-1324, 1571-1571
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@convex/yucpLicenses.ts` at line 1232, The call to getPinnedSigningRoot is
only using process.env.YUCP_ROOT_KEY_ID, dropping the legacy YUCP_KEY_ID
fallback; update each call to pass process.env.YUCP_ROOT_KEY_ID ??
process.env.YUCP_KEY_ID ?? null so the legacy env var is honored (e.g., update
the occurrences that call getPinnedSigningRoot and any similar signing-path
calls to use this combined fallback).
| const roots = keys | ||
| .map((entry) => parseTrustedRootCandidate(entry)) | ||
| .filter((entry): entry is YucpTrustedRoot => entry !== null); | ||
| if (roots.length === 0) { | ||
| throw new Error('invalid trust bundle'); | ||
| } |
There was a problem hiding this comment.
Reject partially-invalid trust bundles instead of dropping bad entries.
Right now an explicit bundle like [valid, invalid] is accepted as [valid]. For security config, that should fail closed; otherwise a typo silently changes the active trust set.
Suggested fix
const roots = keys
.map((entry) => parseTrustedRootCandidate(entry))
.filter((entry): entry is YucpTrustedRoot => entry !== null);
- if (roots.length === 0) {
+ if (roots.length === 0 || roots.length !== keys.length) {
throw new Error('invalid trust bundle');
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const roots = keys | |
| .map((entry) => parseTrustedRootCandidate(entry)) | |
| .filter((entry): entry is YucpTrustedRoot => entry !== null); | |
| if (roots.length === 0) { | |
| throw new Error('invalid trust bundle'); | |
| } | |
| const roots = keys | |
| .map((entry) => parseTrustedRootCandidate(entry)) | |
| .filter((entry): entry is YucpTrustedRoot => entry !== null); | |
| if (roots.length === 0 || roots.length !== keys.length) { | |
| throw new Error('invalid trust bundle'); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/shared/src/yucpTrust.ts` around lines 164 - 169, The current mapping
silently drops invalid entries by filtering out nulls from the
parseTrustedRootCandidate results; change this to fail-fast by detecting any
nulls and throwing instead. Update the code around the keys.map(...
parseTrustedRootCandidate ...) so you collect the raw mapped results, check for
any item === null (optionally recording the index or original entry), and if any
are present throw a descriptive Error like 'invalid trust bundle: contains
invalid entries' (or include offending entries); only when all entries are
non-null convert/cast the array to YucpTrustedRoot[] and proceed.
Inline ownership checks by calling requireOwnedDocument(await ctx.db.get(...)) for guildLink and route, and use guildLink.discordGuildId when inserting download_routes instead of args.guildId. This ensures the created route is tied to the verified guild link owned by the caller and simplifies ownership validation. (tsconfig build info was regenerated.)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 80 out of 82 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async function resolveActorBinding( | ||
| functionReference: unknown, | ||
| args: unknown, | ||
| explicitActor?: ApiActorBinding | ||
| ): Promise<ApiActorBinding | undefined> { | ||
| const functionName = describeFunctionReference(functionReference); | ||
| if ( | ||
| !isApiActorProtectedFunction(functionName) || | ||
| !args || | ||
| typeof args !== 'object' || | ||
| Array.isArray(args) || | ||
| !('apiSecret' in (args as Record<string, unknown>)) | ||
| ) { | ||
| return undefined; | ||
| } | ||
|
|
||
| return explicitActor ?? (await getDefaultServiceActorBinding()) ?? undefined; | ||
| } | ||
|
|
||
| function mergeActorArg(args: unknown, actor: ApiActorBinding): unknown { | ||
| if (!args || typeof args !== 'object' || Array.isArray(args)) { | ||
| return { actor }; | ||
| } | ||
|
|
||
| return { | ||
| ...(args as Record<string, unknown>), | ||
| actor, | ||
| }; |
There was a problem hiding this comment.
getConvexClientFromUrl now auto-injects an actor binding for protected Convex functions, but the current implementation overwrites an explicitly provided actor in the call args with the default service actor. This is a security issue for public routes that pass per-user actor bindings: the client will replace them with the high-privilege api-server service actor whenever INTERNAL_SERVICE_AUTH_SECRET is set. Update the logic to only attach a default actor when the args do not already include actor (and avoid overwriting when present).
| export function applyResponseSecurityHeaders(response: Response): Response { | ||
| const headers = new Headers(response.headers); | ||
|
|
||
| for (const [key, value] of Object.entries(DEFAULT_RESPONSE_SECURITY_HEADERS)) { | ||
| if (!headers.has(key)) { | ||
| headers.set(key, value); | ||
| } | ||
| } | ||
|
|
||
| const contentType = headers.get('content-type')?.toLowerCase() ?? ''; | ||
| if ( | ||
| (contentType.includes('application/json') || contentType.includes('+json')) && | ||
| !headers.has('Cache-Control') | ||
| ) { | ||
| headers.set('Cache-Control', 'no-store'); | ||
| } | ||
|
|
||
| return new Response(response.body, { | ||
| status: response.status, | ||
| statusText: response.statusText, | ||
| headers, | ||
| }); |
There was a problem hiding this comment.
applyResponseSecurityHeaders rebuilds the Response object with new Response(response.body, ...). This can break responses that rely on multi-valued Set-Cookie semantics (some fetch implementations expose getSetCookie() only on the original response headers), and can also subtly change streaming/body handling. Prefer mutating response.headers in-place (or otherwise preserving Set-Cookie values explicitly) instead of constructing a new Response.
| export function resolveConfiguredYucpTrustBundle( | ||
| json: string | null | undefined | ||
| ): YucpTrustBundleConfig { | ||
| if (typeof json !== 'string' || !json.trim()) { | ||
| return { | ||
| version: 1, | ||
| roots: getPinnedYucpRoots(), | ||
| }; | ||
| } | ||
|
|
||
| const parsed = JSON.parse(json) as Record<string, unknown>; | ||
| const version = parsed.version; | ||
| const keys = parsed.keys; | ||
| if (!Number.isInteger(version) || (version as number) < 1 || !Array.isArray(keys)) { | ||
| throw new Error('invalid trust bundle'); | ||
| } |
There was a problem hiding this comment.
resolveConfiguredYucpTrustBundle calls JSON.parse(json) without catching syntax errors. If YUCP_TRUST_BUNDLE_JSON is set but malformed, this will throw a raw SyntaxError and may crash request handling with an inconsistent message (instead of the intended 'invalid trust bundle' failure mode). Wrap the parse in a try/catch and rethrow a normalized error (or return a safe default when appropriate).
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
convex/downloads.ts (1)
281-299: Remove or deprecate the now-ignoredcreateArtifactinputs.This mutation now sources
guildId,sourceChannelId,archiveChannelId,requiredRoleIds, androleLogicfromroute, but the validator still requires callers to send them. That makes the contract misleading and lets client bugs hide behind silently ignored values. Either drop these fields fromargsor make them explicitly deprecated/optional during migration.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@convex/downloads.ts` around lines 281 - 299, The args schema for the createArtifact mutation still requires guildId, sourceChannelId, archiveChannelId, requiredRoleIds, and roleLogic even though the mutation now reads those values from route; remove these fields from the args object in convex/downloads.ts (the createArtifact args definition) so callers are no longer forced to send ignored values, or if you must preserve compatibility, make them explicitly optional/deprecated via v.optional and add a clear TODO/migration comment; update any related validation/tests to match the new args shape.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@convex/downloads.ts`:
- Around line 356-361: Change the conditional that branches on delegated vs
service actor to check args.authUserId !== undefined instead of truthiness;
specifically, in the block using assertDelegatedAuthUserActor(actor,
args.authUserId) and requireOwnedDocument(artifact, args.authUserId) replace if
(args.authUserId) { ... } else { assertServiceActor(actor,
['downloads:service']); } with an explicit if (args.authUserId !== undefined) {
... } else { ... } and make the identical change at the second occurrence around
the assertDelegatedAuthUserActor/requireOwnedDocument/assertServiceActor
sequence so empty strings no longer masquerade as omitted authUserId.
---
Nitpick comments:
In `@convex/downloads.ts`:
- Around line 281-299: The args schema for the createArtifact mutation still
requires guildId, sourceChannelId, archiveChannelId, requiredRoleIds, and
roleLogic even though the mutation now reads those values from route; remove
these fields from the args object in convex/downloads.ts (the createArtifact
args definition) so callers are no longer forced to send ignored values, or if
you must preserve compatibility, make them explicitly optional/deprecated via
v.optional and add a clear TODO/migration comment; update any related
validation/tests to match the new args shape.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 1c43a5a7-db68-4edb-8169-2b62ff72d46c
📒 Files selected for processing (3)
convex/downloads.tsconvex/tsconfig.tsbuildinfopackages/shared/src/apiActor.test.ts
✅ Files skipped from review due to trivial changes (1)
- packages/shared/src/apiActor.test.ts
| if (args.authUserId) { | ||
| assertDelegatedAuthUserActor(actor, args.authUserId); | ||
| requireOwnedDocument(artifact, args.authUserId); | ||
| } else { | ||
| assertServiceActor(actor, ['downloads:service']); | ||
| } |
There was a problem hiding this comment.
Check optional authUserId against undefined, not truthiness.
v.optional(v.string()) still allows '', so if (args.authUserId) treats an empty string the same as “omitted” and falls into the service path. Use an explicit args.authUserId !== undefined check here so malformed delegated-user calls fail deterministically.
Proposed fix
- if (args.authUserId) {
+ if (args.authUserId !== undefined) {
assertDelegatedAuthUserActor(actor, args.authUserId);
requireOwnedDocument(artifact, args.authUserId);
} else {
assertServiceActor(actor, ['downloads:service']);
}- if (args.authUserId) {
+ if (args.authUserId !== undefined) {
assertDelegatedAuthUserActor(actor, args.authUserId);
requireOwnedDocument(artifact, args.authUserId);
} else {
assertServiceActor(actor, ['downloads:service']);
}Also applies to: 387-392
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@convex/downloads.ts` around lines 356 - 361, Change the conditional that
branches on delegated vs service actor to check args.authUserId !== undefined
instead of truthiness; specifically, in the block using
assertDelegatedAuthUserActor(actor, args.authUserId) and
requireOwnedDocument(artifact, args.authUserId) replace if (args.authUserId) {
... } else { assertServiceActor(actor, ['downloads:service']); } with an
explicit if (args.authUserId !== undefined) { ... } else { ... } and make the
identical change at the second occurrence around the
assertDelegatedAuthUserActor/requireOwnedDocument/assertServiceActor sequence so
empty strings no longer masquerade as omitted authUserId.
Modify packages/shared/src/yucpTrust.ts to update YUCP trust handling (bugfix/refactor/improvement). Regenerated convex/tsconfig.tsbuildinfo as part of the TypeScript build output; no manual changes to buildinfo beyond the rebuild.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ae2d779d00
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 'cache-control', | ||
| 'content-disposition', | ||
| 'content-length', | ||
| 'content-type', | ||
| 'location', |
There was a problem hiding this comment.
Preserve content-encoding on proxied responses
The proxy now forwards accept-encoding upstream but filters response headers through PROXY_RESPONSE_HEADER_ALLOWLIST without content-encoding, while returning proxied.body unchanged. If the upstream public API sends gzip/br (which is likely when accept-encoding is forwarded), clients will receive compressed bytes without the corresponding header, causing corrupted downloads or unreadable payloads on proxied routes (for example runtime artifact and runtime package endpoints).
Useful? React with 👍 / 👎.
| actorBinding: await createAuthUserActorBinding({ | ||
| authUserId: oauthResult.token.sub, | ||
| source: 'oauth', | ||
| scopes: oauthResult.token.grantedScopes, | ||
| }), |
There was a problem hiding this comment.
Avoid failing auth on actor-binding creation
resolveAuth now creates an auth-user actor binding during token/API-key validation, and any failure in that step is converted to a 500 internal_error. Since createAuthUserActorBinding throws when INTERNAL_SERVICE_AUTH_SECRET is missing (outside tests), valid credentials can be rejected in non-production setups where this secret is not guaranteed, including endpoints like /v2/me that do not consume actorBinding afterward.
Useful? React with 👍 / 👎.
This pull request introduces several improvements and new utilities across the codebase, focusing on enhancing security, actor-based API access, and cryptographic root handling. The most significant changes are grouped below.
Security and HTTP Response Hardening:
applyResponseSecurityHeaders, inhttpSecurity.tsto enforce default security headers (likeReferrer-PolicyandX-Content-Type-Options) and applyCache-Control: no-storeto JSON responses if not already set. This utility is now applied to all HTTP responses in bothcreateServer.tsandindex.ts, improving baseline response security. [1] [2] [3] [4] [5]API Actor and Service Binding Utilities:
apiActor.tsproviding helpers to create actor bindings for authenticated users and internal services, using secrets from environment variables and supporting test environments.Convex Client Improvements (API Actor Integration):
convex.tsto support API actor bindings:YUCP Trust and JWT Verification Enhancements:
yucpRuntimeCrypto.tsto:Developer Tooling:
cleanup-deprecated.mjs, to safely remove deprecated Impeccable skill files, symlinks, andskills-lock.jsonentries across all harness directories.Test Support:
Summary by CodeRabbit
New Features
Security Enhancements
Bug Fixes
Tests