Skip to content

Ship Drops Studio managed collaborative platform V4 - #7

Merged
svg8bit merged 1 commit into
mainfrom
codex/drops-managed-platform-v4
Jul 31, 2026
Merged

Ship Drops Studio managed collaborative platform V4#7
svg8bit merged 1 commit into
mainfrom
codex/drops-managed-platform-v4

Conversation

@svg8bit

@svg8bit svg8bit commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Outcome

Ships the user-visible Replit/v0-style platform surfaces and integrates the V2/V3/V4 builder, managed backend reference runtime, collaboration, enterprise controls, agent skills, evidence, and production-safe capability reporting.

Verified

  • 699 unit tests: 697 passed, 2 opt-in live skips
  • lint, typecheck, UI guardrails and npm audit passed
  • Next/Vercel and Vinext/Cloudflare-compatible builds passed
  • generated managed custom app typecheck, tests and production build passed
  • V4 browser contract: 10 passed, 2 viewport-inapplicable Axe skips
  • CodeRabbit reviewed all 99 changed files; all four final findings were fixed and revalidated

Provider truth

Environment markers never become working states without runtime health evidence. Durable D1/Postgres, external realtime and enterprise OIDC remain health-gated; the generated app uses a labelled browser-local fallback when no managed capability is configured.

Release

Production promotion is authorized after preview verification. Rollback target before this PR: dpl_GQyKYxXS8dX9QdpiipK6jKxFzoP3.

Summary by CodeRabbit

  • New Features

    • Added platform navigation and dashboards for projects, templates, backend, integrations, organizations, enterprise, and platform capabilities.
    • Added searchable, filterable templates with direct access to presets.
    • Added integration status and setup guidance.
    • Added organization workspace management, membership, projects, and role controls.
    • Added capability consoles for backend and enterprise readiness evidence.
    • Added managed data, authentication, storage, backups, collaboration, audit, and enterprise security support.
  • Bug Fixes

    • Improved preset loading and preview behavior.
    • Enhanced responsive navigation on smaller screens.
  • Documentation

    • Added guidance for platform operations, security, identity, collaboration, backups, and compliance.

@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
drops-studio Ready Ready Preview Jul 31, 2026 12:57am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds Managed Platform V4 infrastructure, enterprise controls, capability-aware platform pages, runtime skill selection, managed project generation, responsive navigation, documentation, and extensive unit, integration, accessibility, and end-to-end tests.

Changes

Platform surfaces

Layer / File(s) Summary
Capability-driven platform surfaces
app/*, components/platform/*, components/drops-studio.tsx, lib/platform-capabilities.ts, app/styles/*, .env.example
Adds shared platform navigation, capability snapshots and API responses, backend, enterprise, integrations, organizations, projects, templates, and platform views with responsive behavior and truthful setup states.

Runtime skills

Layer / File(s) Summary
Runtime skill registry and selection
lib/agent/*, docs/agent/skills/*, tests/agent-*, outputs/agent-evals/*
Expands runtime skill identifiers and definitions, detects integration capabilities, injects selected skill instructions into prompts, and updates related metrics and tests.

Enterprise platform

Layer / File(s) Summary
Enterprise identity, directory, and governance
lib/enterprise-platform/*, docs/platform/ENTERPRISE_*, docs/platform/AUDIT_RETENTION_EXPORT.md, docs/platform/BACKUP_AND_RECOVERY.md, docs/platform/COLLABORATION_ARCHITECTURE.md, tests/enterprise-platform-*
Adds in-memory enterprise organizations, RBAC, OIDC and domain verification, credentials, restrictive policies, collaboration, AI branches, audit chains, lifecycle operations, and validation suites.

Managed platform

Layer / File(s) Summary
Managed platform services and runtime
lib/managed-platform/*, docs/platform/MANAGED_*, docs/platform/REALTIME_AND_WEBHOOKS.md, docs/platform/FUNCTIONS_JOBS_CRON.md, tests/managed-platform-*, tests/platform-capabilities.test.mjs
Adds scoped managed data, authentication, storage, secrets, backups, logs, provider checks, functions, webhooks, jobs, cron, realtime services, and platform assembly with security and approval controls.

Generated templates

Layer / File(s) Summary
Managed project generation
lib/project-template-*.ts, tests/project-template-materializer.test.mjs
Generates managed backend manifests, routes, policies, collection hooks, environment declarations, tests, documentation, and browser-local fallbacks for supported project capabilities.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main change: shipping the Drops Studio managed collaborative platform V4.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/drops-managed-platform-v4

Comment @coderabbitai help to get the list of available commands.

@svg8bit
svg8bit merged commit 82d05fa into main Jul 31, 2026
2 of 4 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (21)
lib/agent/runtime/intelligent-builder.ts-185-194 (1)

185-194: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Gate integration capabilities on verified availability. Filter each integration before adding its kind or proxy capability; demo, setup-required, or unconfigured records currently expose provider skills to loadRuntimeSkills. Add coverage for these states.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/agent/runtime/intelligent-builder.ts` around lines 185 - 194, Update
runtimeSkillCapabilities to include an integration’s kind and proxy capability
only when that integration is verified as available; exclude demo,
setup-required, and unconfigured records before adding capabilities. Reuse the
existing integration availability/status indicators, preserve the current proxy
mappings for eligible integrations, and add coverage for each excluded state.
lib/enterprise-platform/organizations.ts-262-279 (1)

262-279: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

A revoked invitation can be resent.

resendInvitation only rejects accepted invitations. Revoking an invitation is the documented way to cut off access, yet the revoked record can still be used as a template to mint a fresh valid token.

🔒 Suggested guard
     if (prior.acceptedAt) enterpriseError("INVITATION_REPLAY", "Accepted invitation cannot be resent.");
+    if (prior.revokedAt) enterpriseError("INVITATION_REVOKED", "Revoked invitation cannot be resent.");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/enterprise-platform/organizations.ts` around lines 262 - 279, Update
resendInvitation to reject invitations that already have revokedAt set,
alongside the existing acceptedAt guard, before calling inviteMember. Preserve
the current resend flow for active, non-accepted invitations.
lib/enterprise-platform/identity.ts-96-111 (1)

96-111: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

#pending and #usedStates grow without bound.

Expired pending requests are only detected lazily in #assertFresh and never removed, and #usedStates accumulates forever. Every abandoned authorization flow leaks an entry for the process lifetime.

🔒 Suggested pruning on `begin`
     const stateHash = sha256(state);
+    const nowMs = this.#runtime.now().getTime();
+    for (const [key, request] of this.#pending) {
+      if (nowMs > Date.parse(request.expiresAt)) this.#pending.delete(key);
+    }
     this.#pending.set(stateHash, {

Also consider bounding #usedStates (e.g. record the consumption time and drop entries past the 10-minute request window, since a state can no longer be replayed once its pending record is gone).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/enterprise-platform/identity.ts` around lines 96 - 111, Update the OIDC
begin flow around `#pending` and `#usedStates` to prune expired entries before
registering a new request. Remove pending records whose expiration is past the
current runtime time, and remove used-state records older than the 10-minute
request window (using their consumption timestamp or equivalent), while
preserving replay protection for active states.
lib/enterprise-platform/policies.ts-152-171 (1)

152-171: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Numeric action operands are unvalidated, so NaN fails open.

minimum validates policy-side numbers, but evaluation trusts the action payload. NaN > limit and NaN cost comparisons are false, so a non-finite durationSeconds, count, or estimatedCost is reported as allowed; negative count also passes. Since these values reach the evaluator from untyped/JSON callers, enforce them here.

🛡️ Proposed fix
   const deny = (message: string): void => { allowed = false; reason = message; };
+  const positiveInteger = (value: number): boolean => Number.isSafeInteger(value) && value >= 1;
   switch (action.action) {
     case "model.use":
       if (policy.allowedModelProviders && !policy.allowedModelProviders.includes(action.provider)) deny("Model provider is not allowed.");
       else if (action.model && policy.allowedModels && !policy.allowedModels.includes(action.model)) deny("Model is not allowed.");
       else if (policy.byokRequired && !action.byok) deny("BYOK is required.");
       else if (!policy.platformModelsAllowed && !action.byok) deny("Platform-funded models are disabled.");
-      else if ((action.estimatedCost ?? 0) > policy.maxAgentCostPerRun) deny("Agent cost exceeds policy.");
+      else if (action.estimatedCost !== undefined && (!Number.isFinite(action.estimatedCost) || action.estimatedCost < 0)) deny("Estimated agent cost is invalid.");
+      else if ((action.estimatedCost ?? 0) > policy.maxAgentCostPerRun) deny("Agent cost exceeds policy.");
       break;
@@
     case "sandbox.start":
-      if (action.durationSeconds > policy.maxSandboxDuration) deny("Sandbox duration exceeds policy.");
+      if (!positiveInteger(action.durationSeconds)) deny("Sandbox duration is invalid.");
+      else if (action.durationSeconds > policy.maxSandboxDuration) deny("Sandbox duration exceeds policy.");
       break;
     case "agents.parallel":
-      if (action.count > policy.maxParallelAgents) deny("Parallel agent count exceeds policy.");
+      if (!positiveInteger(action.count)) deny("Parallel agent count is invalid.");
+      else if (action.count > policy.maxParallelAgents) deny("Parallel agent count exceeds policy.");
       break;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/enterprise-platform/policies.ts` around lines 152 - 171, Validate numeric
operands in the action evaluation switch before applying policy comparisons:
require finite, non-negative durationSeconds, count, and estimatedCost values,
and deny invalid payloads rather than allowing them through NaN comparisons.
Update the relevant model.use, sandbox.start, and agents.parallel branches while
preserving existing policy-limit checks for valid values.
components/platform/organization-console.tsx-44-75 (1)

44-75: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

No request timeout policy in OrganizationConsole. Both network calls run without an AbortController/timeout, so a hung request pins the component in a state (loading or creating) that disables every control and offers no recovery — unlike PlatformCapabilityConsole, which bounds its fetch at 10s.

  • components/platform/organization-console.tsx#L44-L75: add a 10s abort to the /api/teams GET, surface a distinct timeout message, and abort on unmount from the effect.
  • components/platform/organization-console.tsx#L82-L88: add the same bounded signal to the workspace-creation POST so creating always clears.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/platform/organization-console.tsx` around lines 44 - 75, Update
refresh in OrganizationConsole to use a 10-second AbortController timeout for
the /api/teams GET, display a distinct timeout message, and abort the request
during effect cleanup on unmount. Also add the same bounded abort signal to the
workspace-creation POST near the creation handler so its creating state always
resolves; apply these changes at components/platform/organization-console.tsx
ranges 44-75 and 82-88.
lib/project-template-managed.ts-122-140 (1)

122-140: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

The blanket catch reports every failure as setup-required, which pushes healthy projects into the browser-local fallback.

An undeclared collection name, an upstream 5xx, a redirect: "error" rejection, and an AbortSignal.timeout expiry all collapse into 503 setup-required. The generated client (Line 185) treats any non-OK response as "managed backend unavailable" and permanently switches to browser-local storage, so a transient upstream blip silently diverges user data from the managed store. Separate the configuration case from request failures.

🛠️ Proposed fix: distinguish failure classes
 async function forward(request: Request, params: Promise<{ collection: string }>, method: "GET" | "POST") {
+  let name: string;
   try {
     if (method === "POST" && !sameOrigin(request)) return Response.json({ state: "permission-denied" }, { status: 403 });
-    const name = await collection(params);
+    name = await collection(params);
+  } catch {
+    return Response.json({ state: "unknown-collection" }, { status: 404, headers: { "cache-control": "private, no-store" } });
+  }
+  try {
     const body = method === "POST" ? await request.text() : undefined;
     if (body && new TextEncoder().encode(body).byteLength > BODY_LIMIT_BYTES) return Response.json({ state: "quota-exceeded" }, { status: 413 });
     const upstream = await managedBackendRequest("/v1/collections/" + encodeURIComponent(name), {
       method,
       headers: body ? { "content-type": "application/json" } : undefined,
       body,
     });
     const payload = await upstream.text();
     return new Response(payload, {
       status: upstream.status,
       headers: { "cache-control": "private, no-store", "content-type": upstream.headers.get("content-type") || "application/json" },
     });
-  } catch {
-    return Response.json({ state: "setup-required", message: "Managed backend is not configured; use the labelled browser-local fallback." }, { status: 503, headers: { "cache-control": "private, no-store" } });
+  } catch (cause) {
+    const unconfigured = cause instanceof Error && cause.message.includes("capability is not configured");
+    return Response.json(
+      unconfigured
+        ? { state: "setup-required", message: "Managed backend is not configured; use the labelled browser-local fallback." }
+        : { state: "upstream-unavailable" },
+      { status: unconfigured ? 503 : 502, headers: { "cache-control": "private, no-store" } },
+    );
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/project-template-managed.ts` around lines 122 - 140, Update the managed
collection request handler around managedBackendRequest so only the missing or
invalid backend configuration returns the 503 setup-required response. Let
collection lookup errors, upstream non-OK responses, redirect rejections, and
timeout failures retain their actual failure status or propagate instead of
entering the setup-required fallback, ensuring the generated client does not
switch to browser-local storage for request failures.
lib/enterprise-platform/lifecycle.ts-258-258 (1)

258-258: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Restore approval contract is weaker in code than in the docs. The single root cause is that production protection is inferred from one exact environment string, so the documented guarantees (unconditional approval, separate-environment default, target isolation, audit evidence) are not enforced.

  • lib/enterprise-platform/lifecycle.ts#L258-L258: replace the toLowerCase() === "production" check with a protected-environment classification (configured name set or caller-supplied flag), and add the target-isolation check plus recorded audit evidence in completeRestore.
  • docs/platform/BACKUP_AND_RECOVERY.md#L3-L7: restate the approval/isolation/audit-evidence claims to match what planRestore/completeRestore actually enforce, or update them once the code is tightened.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/enterprise-platform/lifecycle.ts` at line 258, Strengthen
production-restore protection across lib/enterprise-platform/lifecycle.ts: in
the approval check near completeRestore, replace the exact lowercase
"production" comparison with the configured protected-environment classification
or caller-supplied flag, enforce target isolation, and record audit evidence;
update docs/platform/BACKUP_AND_RECOVERY.md lines 3-7 to accurately describe the
approval, isolation, and audit guarantees enforced by planRestore and
completeRestore.
lib/enterprise-platform/branches.ts-206-216 (1)

206-216: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Restore discards the current canonical state with no recovery point.

mergeBranch snapshots before overwriting (Line 160), but restoreCheckpoint replaces project.files outright — an incorrect restore permanently loses everything written since the target checkpoint.

🛡️ Proposed fix
     const checkpoint = this.#checkpoints.get(input.checkpointId);
     if (!checkpoint || checkpoint.projectId !== project.projectId) enterpriseError("NOT_FOUND", "Checkpoint was not found for this project.");
+    this.#checkpoint(project, input.actorUserId, "manual");
     project.files = clone(checkpoint.files);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/enterprise-platform/branches.ts` around lines 206 - 216, Update
restoreCheckpoint to create a recovery checkpoint of the current canonical
project state before replacing project.files with the selected checkpoint,
matching the pre-overwrite snapshot behavior in mergeBranch. Preserve the
existing revision validation, target checkpoint lookup, metadata updates, and
restored-state return behavior.
lib/enterprise-platform/collaboration.ts-312-340 (1)

312-340: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Scope comment authorization by project/organization.

can accepts no scope, while thread operations never validate thread.projectId; a granted user can access or mutate any thread in the store. Bind the store to a scope or authorize each operation against the thread’s project.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/enterprise-platform/collaboration.ts` around lines 312 - 340, Update the
collaboration thread operations, including reply, resolve, reopen, and thread,
to enforce project/organization scope before reading or mutating a thread. Bind
the collaboration store to its configured scope and validate each thread’s
projectId against it, or pass the thread project scope into authorization;
ensure users granted access in one scope cannot access or modify threads from
another.
lib/managed-platform/backups.ts-111-122 (1)

111-122: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Restore is not atomic — a failure after importState leaves the target half-restored.

importState (Line 117) mutates the target environment before importMetadata and importReferences run. If either later step throws, the target keeps the restored rows/schema but loses auth metadata and secret references, and there is no rollback or compensating cleanup. Since the guard at Line 114 only blocks non-empty targets when overwrite is unset, a retried overwrite restore then operates on a partially-mutated environment.

Stage the three imports (build then commit, or snapshot the target state and roll back on failure) so the target only changes once all components validate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/managed-platform/backups.ts` around lines 111 - 122, The restore flow in
restore must be atomic across importState, importMetadata, and importReferences:
stage and validate all three components before committing them, or snapshot the
existing target and roll back on any failure. Ensure a failed restore leaves the
target unchanged, including overwrite restores, while preserving the existing
approval and validation checks.
lib/managed-platform/runtime-services.ts-251-259 (1)

251-259: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Intl.DateTimeFormat is constructed on every minute of the search loop.

zonedParts builds a fresh formatter per call, and the loop at Line 293 can call it up to 527,040 times. Formatter construction is by far the most expensive part of this operation; a sparse-but-valid expression (e.g. 0 0 29 2 *) will burn hundreds of thousands of constructions on a request thread. Build one formatter per nextCronOccurrence call and reuse it.

⚡ Proposed fix: hoist the formatter
-function zonedParts(date: Date, timezone: string): { minute: number; hour: number; day: number; month: number; weekday: number } {
-  let formatter: Intl.DateTimeFormat;
-  try {
-    formatter = new Intl.DateTimeFormat("en-US", { timeZone: timezone, minute: "2-digit", hour: "2-digit", hourCycle: "h23", day: "2-digit", month: "2-digit", weekday: "short" });
-  } catch { throw new ManagedPlatformError("CRON_TIMEZONE_INVALID", "Cron timezone is invalid."); }
-  const parts = Object.fromEntries(formatter.formatToParts(date).map((part) => [part.type, part.value]));
+function zonedFormatter(timezone: string): Intl.DateTimeFormat {
+  try {
+    return new Intl.DateTimeFormat("en-US", { timeZone: timezone, minute: "2-digit", hour: "2-digit", hourCycle: "h23", day: "2-digit", month: "2-digit", weekday: "short" });
+  } catch { throw new ManagedPlatformError("CRON_TIMEZONE_INVALID", "Cron timezone is invalid."); }
+}
+
+function zonedParts(date: Date, formatter: Intl.DateTimeFormat): { minute: number; hour: number; day: number; month: number; weekday: number } {
+  const parts = Object.fromEntries(formatter.formatToParts(date).map((part) => [part.type, part.value]));
   const weekdays: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
   return { minute: Number(parts.minute), hour: Number(parts.hour), day: Number(parts.day), month: Number(parts.month), weekday: weekdays[parts.weekday] };
 }
   const cursor = new Date(Math.floor(from.getTime() / 60_000) * 60_000 + 60_000);
+  const formatter = zonedFormatter(timezone);
   for (let iteration = 0; iteration < 527_040; iteration++, cursor.setUTCMinutes(cursor.getUTCMinutes() + 1)) {
-    const current = zonedParts(cursor, timezone);
+    const current = zonedParts(cursor, formatter);

Skipping ahead by whole hours/days when the hour or day field cannot match would cut the worst case further, if you want to go beyond the cheap win.

Also applies to: 286-305

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/managed-platform/runtime-services.ts` around lines 251 - 259, Update the
nextCronOccurrence flow and zonedParts helper so one Intl.DateTimeFormat
instance is created per nextCronOccurrence call and reused throughout the search
loop, while preserving the existing invalid-timezone ManagedPlatformError
behavior. Pass the formatter into zonedParts instead of constructing it on every
invocation.
lib/managed-platform/runtime-services.ts-325-339 (1)

325-339: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

due() re-fires forever — nothing ever advances nextRunAt or sets lastRunAt.

schedules is private and the class exposes no mutator, so once a schedule is due every subsequent due() call returns it again, and any poller driving jobs off this would enqueue duplicates indefinitely. lastRunAt and overlapPolicy are stored but never read or written.

Add an acknowledge/advance step (recomputing nextRunAt via nextCronOccurrence and honoring overlapPolicy) so a completed tick moves the schedule forward.

🐛 Sketch of the missing mutator
   due(scope: ManagedScope): ManagedSchedule[] {
     return clone([...this.schedules.values()].filter((schedule) => schedule.scopeKey === scope.scopeKey && schedule.enabled && schedule.nextRunAt && Date.parse(schedule.nextRunAt) <= this.options.now().getTime()));
   }
+
+  markRan(scope: ManagedScope, scheduleId: string): ManagedSchedule {
+    const schedule = this.schedules.get(scheduleId);
+    if (!schedule || schedule.scopeKey !== scope.scopeKey) throw new ManagedPlatformError("CRON_NOT_FOUND", "Cron schedule does not exist in this scope.");
+    const ranAt = this.options.now();
+    schedule.lastRunAt = ranAt.toISOString();
+    schedule.nextRunAt = schedule.enabled ? nextCronOccurrence(schedule.expression, schedule.timezone, ranAt) : null;
+    this.options.logs.append(scope, { category: "cron", severity: "info", action: "cron.tick", actorId: "cron-runner", requestId: schedule.id, metadata: { scheduleId: schedule.id, nextRunAt: schedule.nextRunAt } });
+    return clone(schedule);
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/managed-platform/runtime-services.ts` around lines 325 - 339, Add a
public acknowledgement/advance method alongside due() that accepts a schedule
identifier and completion context, updates lastRunAt, and recomputes nextRunAt
with nextCronOccurrence so acknowledged ticks are not returned repeatedly.
Enforce the schedule’s overlapPolicy when advancing, preserve disabled schedules
as unscheduled, and return a cloned updated schedule; keep due() scoped to
discovering currently due schedules.
lib/managed-platform/secrets.ts-86-108 (1)

86-108: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

rotate and revoke emit no audit entries.

create appends a secret.create log (Line 64) but the two other lifecycle mutations are silent, so rotation/revocation cannot be reconstructed from the managed log store — which docs/platform/MANAGED_PLATFORM_SECURITY.md claims is covered by audit integrity suites.

🔒️ Proposed fix
     secret.versions.push(replacement);
     secret.status = "active";
+    this.options.logs.append(scope, { category: "secret", severity: "info", action: "secret.rotate", actorId: principal.actorId, requestId: `req_${randomUUID()}`, metadata: { secretId: secret.id, name: secret.name, version: nextVersion } });
     return metadata(secret);
   }
@@
     secret.status = "revoked";
     const current = secret.versions.at(-1);
     if (current) current.revokedAt = this.options.now().toISOString();
+    this.options.logs.append(scope, { category: "secret", severity: "warning", action: "secret.revoke", actorId: principal.actorId, requestId: `req_${randomUUID()}`, metadata: { secretId: secret.id, name: secret.name } });
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/managed-platform/secrets.ts` around lines 86 - 108, Update the rotate and
revoke methods to append audit entries for their successful lifecycle mutations,
matching the existing secret.create logging pattern and using the appropriate
secret.rotate and secret.revoke event details. Ensure entries are written only
after the corresponding mutation succeeds and retain the existing permission and
availability checks.
lib/managed-platform/security.ts-70-74 (1)

70-74: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Approval receipts are format-checked only, so they can be self-minted.

Any string matching /^approval_[a-z0-9_-]{8,160}$/i passes, including a literal like approval_aaaaaaaa. Since this gates production and destructive schema migrations (lib/managed-platform/data.ts Line 187), the control provides no real assurance that an approval was actually granted. Consider signing receipts with the platform key (signPayload) and verifying scope, plan checksum, approver identity, and expiry here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/managed-platform/security.ts` around lines 70 - 74, Replace the
format-only validation in requireApproval with cryptographic receipt
verification using the platform signing key and signPayload-compatible verifier.
Require and validate the receipt’s scope, plan checksum, approver identity, and
expiry before allowing production or destructive migrations, while preserving
ManagedPlatformError for invalid or missing approvals.
lib/managed-platform/storage.ts-39-55 (1)

39-55: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Object keys are not unique within a scope.

put always inserts a new object_<uuid> entry, so writing the same key twice leaves two active objects sharing that key with no version or overwrite semantics. exportMetadata then returns duplicate keys, and any consumer resolving an object by key has no deterministic winner. Either reject/replace on existing active (scopeKey, key) or add explicit versioning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/managed-platform/storage.ts` around lines 39 - 55, The put method
currently permits multiple active objects with the same key in a scope. Update
put to enforce uniqueness for each active (scope.scopeKey, input.key) pair by
either rejecting duplicates or replacing the existing active object, and ensure
object storage and metadata export cannot expose duplicate keys; do not add
versioning unless explicitly supported by the surrounding API.
lib/managed-platform/data.ts-358-362 (1)

358-362: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Principal-less state helpers are public on both service classes. importState/exportState and exportMetadata accept only a ManagedScope — no ManagedPrincipal, no assertScope, no requirePermission — unlike every other method on these classes. They are presumably internal to the backup/restore path, but as public class members they are a scope-crossing read/write primitive one refactor away from being exposed.

  • lib/managed-platform/data.ts#L358-L362: confirm only the backup service calls these, and either mark them internal or take a principal and enforce assertScope + a backend.backups.manage check; importState should also validate the incoming schema before replacing environment state.
  • lib/managed-platform/storage.ts#L79-L81: apply the same treatment to exportMetadata.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/managed-platform/data.ts` around lines 358 - 362, The principal-less
backup helpers are publicly callable scope-crossing primitives. In
lib/managed-platform/data.ts lines 358-362, update importState/exportState to be
internal if only the backup service uses them, or require a ManagedPrincipal and
enforce assertScope plus the backend.backups.manage permission; also validate
the incoming schema before importState replaces state. In
lib/managed-platform/storage.ts lines 79-81, apply the same internalization or
principal-and-authorization treatment to exportMetadata.
lib/managed-platform/security.ts-76-88 (1)

76-88: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use a locale-independent comparator for canonical key ordering.

localeCompare is locale/ICU-dependent, so the same object can serialize differently across runtimes or LANG settings. Since stableJson feeds sha256 checksums that are produced and verified in separate calls (e.g. migration plan checksum in lib/managed-platform/data.ts Lines 167 and 186, unique-constraint comparison, backup hashes), a locale difference can surface as spurious MIGRATION_TAMPERED / integrity failures. Plain code-unit comparison is deterministic everywhere.

🔧 Deterministic key ordering
     return Object.fromEntries(Object.entries(value as Record<string, unknown>)
-      .sort(([left], [right]) => left.localeCompare(right))
+      .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
       .map(([key, nested]) => [key, canonicalize(nested)]));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/managed-platform/security.ts` around lines 76 - 88, Update the key sort
comparator in canonicalize to use deterministic plain code-unit ordering instead
of localeCompare, while preserving recursive canonicalization and stableJson
behavior.
lib/managed-platform/data.ts-327-334 (1)

327-334: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Update idempotency fingerprint/key omit the collection name.

Unlike create (Line 243), the update fingerprint and the map key ${actorId}:update:${key} contain no collection. Reusing one idempotency key across two collections with the same row id and patch replays the first collection's record instead of raising IDEMPOTENCY_CONFLICT, returning a record from the wrong collection.

🔧 Scope update idempotency by collection
-    const fingerprint = sha256(stableJson({ action: "update", id, options: { expectedRevision: options.expectedRevision }, data }));
+    const fingerprint = sha256(stableJson({ action: "update", collectionName, id, options: { expectedRevision: options.expectedRevision }, data }));
     if (options.idempotencyKey) {
-      const previous = state.idempotency.get(`${principal.actorId}:update:${options.idempotencyKey}`);
+      const previous = state.idempotency.get(`${principal.actorId}:update:${collectionName}:${options.idempotencyKey}`);

Apply the same key change at Line 339.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/managed-platform/data.ts` around lines 327 - 334, Update the update
idempotency logic around the fingerprint and state.idempotency lookup to include
the collection name in both the hashed input and map key, matching the create
flow. Apply the same collection-scoped key construction at the related line 339
usage, preserving replay for identical requests and IDEMPOTENCY_CONFLICT for
differing requests.
lib/managed-platform/data.ts-286-294 (1)

286-294: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Unknown filter operators silently fall through to lte.

compare has no operator whitelist: any value other than the seven handled cases hits the final return left <= right. Since filters arrive as data (and are only checked for in-array shape at Line 280), a typo'd or unsupported operator returns a wrong result set instead of failing. Validate operators explicitly.

🔧 Reject unsupported operators
+    const OPERATORS = new Set(["eq", "ne", "in", "gt", "gte", "lt", "lte"]);
+    for (const filter of filters) {
+      if (!OPERATORS.has(filter.operator)) throw new ManagedPlatformError("QUERY_OPERATOR", `Query operator ${filter.operator} is invalid.`);
+    }
     const compare = (left: unknown, operator: string, right: unknown) => {
       if (operator === "eq") return stableJson(left) === stableJson(right);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/managed-platform/data.ts` around lines 286 - 294, Update the compare
function to explicitly validate the operator against the supported eq, ne, in,
gt, gte, lt, and lte values before evaluating it. Reject unsupported operators
instead of allowing them to fall through to the lte comparison, while preserving
the existing behavior for valid operators.
lib/managed-platform/data.ts-99-115 (1)

99-115: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

add-field bypasses collection-level schema validation.

The add-field branch only checks the field name and the default value, so invariants enforced by validateCollection are skipped:

  • the 100-field cap (Line 73) can be exceeded by repeated add-field migrations;
  • the enum consistency rule (Lines 76-78) is skipped, so { type: "enum" } with missing/duplicate enumValues is accepted and every later write to that field fails FIELD_TYPE_INVALID.

Re-validating the mutated collection keeps all branches consistent (and matches how add-index re-runs validateCollection).

🔧 Re-validate after add-field
   if (operation.kind === "add-field") {
     if (!NAME.test(operation.field) || collection.fields[operation.field]) throw new ManagedPlatformError("FIELD_EXISTS", `Field ${operation.field} already exists or is invalid.`);
-    validateFieldValue(operation.field, operation.definition, operation.definition.default);
-    collection.fields[operation.field] = clone(operation.definition);
+    snapshot.collections[collection.name] = validateCollection({
+      ...collection,
+      fields: { ...collection.fields, [operation.field]: clone(operation.definition) },
+    });
   } else if (operation.kind === "rename-field") {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/managed-platform/data.ts` around lines 99 - 115, Update the add-field
branch in the migration operation handler to re-run validateCollection after
inserting the cloned field definition, preserving the existing name and
default-value checks. Ensure the mutated collection is validated before the
operation completes so collection-wide limits and enum invariants match the
add-index branch.
lib/managed-platform/auth.ts-50-71 (1)

50-71: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Add a per-scope/email issuance rate limit before sending codes.

Each challenge has an independent 6-digit code, so unlimited challenges provide unlimited guesses and enable email bombing; the five-attempt cap only applies per challenge. Add a configurable windowed limit before deliverOneTimeCode (the existing backend.auth.manage check does not replace abuse protection).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/managed-platform/auth.ts` around lines 50 - 71, Add a configurable
windowed issuance limit keyed by scope.scopeKey and normalizedEmail in
requestEmailCode, checking it after validation and adapter availability but
before deliverOneTimeCode. Reject requests exceeding the limit and record
successful issuance timestamps so separate challenges cannot bypass the
per-scope/email cap; preserve the existing permission and challenge behavior
otherwise.
🟡 Minor comments (16)
lib/enterprise-platform/identity.ts-156-158 (1)

156-158: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Group→role resolution picks the alphabetically first mapped group, not the most privileged.

claims.groups is sorted in issueLocalTestCode (Line 141), so a user in both admins and viewers-readonly resolves by lexical order rather than privilege. Make the precedence explicit (e.g. rank against DEFAULT_ROLE_IDS) so role assignment is predictable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/enterprise-platform/identity.ts` around lines 156 - 158, Update the role
resolution in the pending-claims flow to choose the most privileged mapped group
rather than the first entry in sorted claims.groups. Rank resolved roles using
the existing DEFAULT_ROLE_IDS precedence, select the highest-priority role, and
retain "viewer" as the fallback when no group maps to a role.
lib/enterprise-platform/identity.ts-223-241 (1)

223-241: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Unverified domain claims are permanent, and rotateChallenge duplicates the ownership check.

An organization that creates a challenge and never verifies it blocks every other organization from claiming that domain forever, since the map entry is keyed by domain and never expires. Consider releasing the claim when the challenge has expired and verifiedAt is null. rotateChallenge (Lines 236-241) then just forwards to createChallenge, which already performs the identical check — the duplicated guard can go.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/enterprise-platform/identity.ts` around lines 223 - 241, Update
createChallenge to remove an existing domain entry when its expiresAt is in the
past and verifiedAt is null, allowing another organization to claim the expired
unverified domain; retain verified claims and active challenges. Remove the
redundant ownership check and related existing lookup from rotateChallenge,
leaving it to delegate directly to createChallenge.
lib/enterprise-platform/organizations.ts-150-167 (1)

150-167: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Workspace creator can be locked out of the workspace it just created.

Line 165 seeds explicit membership with the org owner only. An actor holding workspace.manage through a custom role (i.e. roleId is neither owner nor admin) fails #assertWorkspaceAccess (Line 430) for the workspace it created, so it cannot then call createProject there.

🐛 Suggested fix
-    this.#workspaceMembers.set(workspace.id, new Set([organization.ownerUserId]));
+    this.#workspaceMembers.set(workspace.id, new Set([organization.ownerUserId, input.actorUserId]));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/enterprise-platform/organizations.ts` around lines 150 - 167, Update
createWorkspace to include the creating actor identified by input.actorUserId in
the new workspace’s explicit membership set, while preserving the organization
owner membership. Ensure custom-role users who pass `#assertPermission` retain
access for subsequent workspace operations such as createProject.
lib/enterprise-platform/organizations.ts-224-260 (1)

224-260: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Invitations remain acceptable after the organization is archived.

acceptInvitation never consults the organization record, so a membership is created for an archived org (whose #effectivePermissions returns []), leaving a member with no permissions and no signal why.

🛡️ Suggested guard
     const userId = assertSafeId(input.userId, "User id");
+    if (this.#organization(invitation.organizationId).archivedAt) {
+      enterpriseError("INVALID_INPUT", "Archived organization cannot accept invitations.");
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/enterprise-platform/organizations.ts` around lines 224 - 260, Update
acceptInvitation to retrieve the invitation’s organization record and reject the
operation when that organization is archived, before mutating invitation or
membership state. Use the existing organization lookup and error-handling
conventions in the class, while preserving the current validation and acceptance
behavior for active organizations.
lib/enterprise-platform/utils.ts-63-76 (1)

63-76: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

canonicalize collapses non-plain objects (e.g. Date) to {}.

Object.entries(new Date()) is empty, so any Date (or Map/Set) nested in a value silently canonicalizes to {}. Since stableJson feeds audit integrity hashing (audit.ts Line 63) and export checksums, two events with different Date metadata produce identical hashes. JSON.stringify would have preserved toJSON() output here.

♻️ Suggested normalization
 function canonicalize(value: unknown): unknown {
   if (Array.isArray(value)) return value.map(canonicalize);
   if (!value || typeof value !== "object") return value;
+  if (typeof (value as { toJSON?: unknown }).toJSON === "function") {
+    return canonicalize((value as { toJSON(): unknown }).toJSON());
+  }
   return Object.fromEntries(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/enterprise-platform/utils.ts` around lines 63 - 76, Update canonicalize
to preserve non-plain objects’ JSON representations instead of converting them
through Object.entries into {}. Keep recursive key sorting and undefined
filtering for plain objects, while allowing values such as Date, Map, and Set to
retain their established JSON.stringify/toJSON behavior so stableJson produces
distinct integrity hashes.
lib/project-template-managed.ts-115-119 (1)

115-119: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

new URL(origin) throws for opaque origins.

Requests carrying Origin: null (sandboxed iframe, some redirect flows) make sameOrigin throw rather than return false, so the caller's catch reports setup-required instead of denying the write. Parse defensively and treat unparsable origins as cross-origin.

🛡️ Proposed fix
 function sameOrigin(request: Request) {
   if (request.headers.get("sec-fetch-site")?.toLowerCase() === "cross-site") return false;
   const origin = request.headers.get("origin");
-  return !origin || new URL(origin).origin === new URL(request.url).origin;
+  if (!origin) return true;
+  try {
+    return new URL(origin).origin === new URL(request.url).origin;
+  } catch {
+    return false;
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/project-template-managed.ts` around lines 115 - 119, Update sameOrigin to
defensively parse the request’s origin and return false when it is opaque or
otherwise invalid, including Origin: null, instead of allowing new URL(origin)
to throw. Preserve the existing sec-fetch-site cross-site rejection and
same-origin comparison for valid origins.
lib/project-template-managed.ts-214-217 (1)

214-217: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unguarded localStorage.setItem makes addItem reject in the fallback path.

Quota exhaustion or storage-restricted browsing throws here, so the item is already in React state but the returned promise rejects — the exact opposite of a graceful fallback. Wrap the write in try/catch.

🛠️ Proposed fix
     const next = [{ id: crypto.randomUUID(), title: normalized }, ...items].slice(0, 100);
     setItems(next);
-    localStorage.setItem(localKey(collection), JSON.stringify(next));
+    try { localStorage.setItem(localKey(collection), JSON.stringify(next)); } catch { /* session-only fallback */ }
     return true;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/project-template-managed.ts` around lines 214 - 217, Wrap the
localStorage.setItem call in addItem’s fallback path with try/catch so quota or
storage errors do not reject after setItems succeeds. Keep the in-memory update
and successful return behavior unchanged, and safely ignore or handle the
storage write failure.
tests/rendered-html.test.mjs-14-14 (1)

14-14: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the headline assertion to account for the <br> element.

The rendered headline splits Build crypto apps and 10x faster with AI around <br>, so the contiguous regex cannot match the built HTML.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/rendered-html.test.mjs` at line 14, Update the headline assertion in
the rendered HTML test to match the text across the inserted <br> element, while
still verifying both “Build crypto apps” and “10x faster with AI” appear in the
rendered output.
components/platform/integration-catalog.tsx-36-38 (1)

36-38: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Publish a same-document update after session marker changes.

storage never fires in the window that performed the sessionStorage write, and sessionStorage is tab-scoped. The catalog can retain “Setup required” after a connection is configured until it remounts. Subscribe to a custom connection-change event (or shared external store) and emit it from the marker writer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/platform/integration-catalog.tsx` around lines 36 - 38, Update
subscribeToSessionConnections to listen for a custom connection-change event in
addition to storage, and ensure its cleanup removes both listeners. Locate the
session marker writer and dispatch that same event immediately after successful
marker changes so the catalog refreshes within the current tab without requiring
a remount.
lib/enterprise-platform/collaboration.ts-73-73 (1)

73-73: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Duplicate (identical) inserts trigger a false "unreachable operations" error.

validateOperations tolerates repeated operationIds whose content matches, but inserts counts duplicates while visited is a set — so visited.size !== inserts.length fails on a document that renders fine (e.g. a deserialized document that was concatenated rather than merged).

🐛 Proposed fix
-  if (visited.size !== inserts.length) enterpriseError("COLLABORATION_OPERATION_INVALID", "Collaborative document contains unreachable operations.");
+  const uniqueInsertIds = new Set(inserts.map((insert) => insert.operationId));
+  if (visited.size !== uniqueInsertIds.size) enterpriseError("COLLABORATION_OPERATION_INVALID", "Collaborative document contains unreachable operations.");

Also applies to: 103-103

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/enterprise-platform/collaboration.ts` at line 73, Update
validateOperations’ unreachable-operation comparison to count unique operation
IDs rather than raw entries in inserts, matching the duplicate-tolerant behavior
of validateOperations and the visited set. Preserve detection of genuinely
unvisited inserts while allowing repeated identical operationId values.
docs/platform/BACKUP_AND_RECOVERY.md-3-7 (1)

3-7: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Documented restore guarantees exceed the implementation.

planRestore in lib/enterprise-platform/lifecycle.ts only requires approval when the target is literally production, does not enforce target isolation, and completeRestore records no audit evidence. Align the wording (or the code) so the doc is not read as a hard guarantee.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/platform/BACKUP_AND_RECOVERY.md` around lines 3 - 7, Align the restore
documentation with the actual behavior of planRestore and completeRestore: do
not claim that approval is required for every production-destructive target,
that restore targets are isolated, or that audit evidence is recorded unless
those guarantees are implemented. Update the statements describing approval,
target isolation, and recovery verification to accurately reflect the current
lifecycle behavior.
lib/enterprise-platform/credentials.ts-108-116 (1)

108-116: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

rotateToken accepts expired tokens.

Only revokedAt blocks rotation, so an expired token can be silently exchanged for a fresh valid one — the expiry boundary enforced in authenticate (Line 146) is bypassed via rotation.

🔐 Proposed fix
     const prior = this.#token(input.tokenId);
     if (prior.revokedAt) enterpriseError("TOKEN_REVOKED", "API token is already revoked.");
+    if (this.#runtime.now().getTime() > Date.parse(prior.expiresAt)) enterpriseError("TOKEN_EXPIRED", "Expired API tokens cannot be rotated.");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/enterprise-platform/credentials.ts` around lines 108 - 116, Update
rotateToken to reject tokens whose expiresAt is at or before the current runtime
time, in addition to the existing revokedAt check. Use the same expiry-boundary
behavior and runtime clock as authenticate, before issuing the replacement
token; leave valid, non-revoked token rotation unchanged.
lib/managed-platform/security.ts-122-125 (1)

122-125: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Redact before truncating.

Truncation at 2,000 chars runs first, so a secret spanning the cut is sliced and its surviving prefix may no longer match SECRET_VALUE, leaking partial credential material into logs. Swapping the order removes that window.

🔒 Reorder redaction and truncation
 function sanitizeString(value: string): string {
-  const bounded = value.length > 2_000 ? `${value.slice(0, 2_000)}…[TRUNCATED]` : value;
-  return bounded.replace(SECRET_VALUE, "[REDACTED]");
+  const redacted = value.replace(SECRET_VALUE, "[REDACTED]");
+  return redacted.length > 2_000 ? `${redacted.slice(0, 2_000)}…[TRUNCATED]` : redacted;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/managed-platform/security.ts` around lines 122 - 125, Update
sanitizeString so SECRET_VALUE redaction occurs before the 2,000-character
truncation, ensuring secrets crossing the truncation boundary cannot leak
partial credential material while preserving the existing truncation marker and
output behavior.
tests/managed-platform-services.test.mjs-3-12 (2)

3-12: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Raise the minimum Node.js engine to >=22.15.0 or avoid module.registerHooks; the current >=22.13.0 range includes unsupported Node.js versions 22.13–22.14.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/managed-platform-services.test.mjs` around lines 3 - 12, Update the
project’s Node.js engine requirement to >=22.15.0 wherever the current >=22.13.0
constraint is declared, so the registerHooks usage in the test setup only runs
on supported versions.

3-12: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Avoid registerHooks while Node 22 remains supported. package.json declares node >=22.13.0, but module.registerHooks was introduced in Node 23.5.0 and is unavailable on Node 22, so this test fails during import on a supported runtime.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/managed-platform-services.test.mjs` around lines 3 - 12, Replace the
registerHooks-based alias resolution in the test setup with a mechanism
supported by Node 22.13.0, and remove the dependency on module.registerHooks
while preserving the existing "`@/`..." TypeScript path resolution behavior used
by the tests.
lib/managed-platform/platform.ts-67-78 (1)

67-78: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Authorize before backup verification

dataCore.apply rechecks assertScope and requirePermission, so this does not bypass authorization. However, unauthorized callers can still distinguish unknown, corrupt, wrong-scope, and valid backup IDs before those checks. Move the scope and permission checks ahead of backups.verifyForScope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/managed-platform/platform.ts` around lines 67 - 78, Update the
schema.apply method to perform the existing scope and permission authorization
checks before calling backups.verifyForScope for destructive production plans.
Preserve the current backup-required error and dataCore.apply flow, using the
same authorization mechanisms already enforced by dataCore.apply.

Comment on lines +186 to +222
inviteMember(input: {
actorUserId: string;
organizationId: string;
workspaceId?: string;
email: string;
roleId: string;
expiresInMs: number;
}): { invitation: InvitationRecord; token: string } {
this.#assertPermission(input.actorUserId, input.organizationId, "members.manage");
if (input.workspaceId && this.#workspace(input.workspaceId).organizationId !== input.organizationId) {
enterpriseError("TENANT_MISMATCH", "Invitation workspace belongs to another organization.");
}
this.#permissionsForRole(input.organizationId, input.roleId);
if (!Number.isSafeInteger(input.expiresInMs) || input.expiresInMs < 1_000 || input.expiresInMs > 30 * 86_400_000) {
enterpriseError("INVALID_INPUT", "Invitation expiry is invalid.");
}
const token = this.#runtime.token();
if (token.length < 32) enterpriseError("INVALID_INPUT", "Invitation token entropy is insufficient.");
const now = this.#runtime.now();
const invitation: InvitationRecord = {
id: assertSafeId(this.#runtime.id("invitation"), "Invitation id"),
organizationId: input.organizationId,
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
email: normalizeEmail(input.email),
roleId: input.roleId,
tokenHash: sha256(token),
expiresAt: iso(new Date(now.getTime() + input.expiresInMs)),
revokedAt: null,
acceptedAt: null,
acceptedByUserId: null,
replacedInvitationId: null,
resendCount: 0,
createdAt: iso(now),
};
this.#invitations.set(invitation.id, invitation);
return { invitation: clone(invitation), token };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Privilege escalation: inviteMember doesn't bound the invited role by the actor's own permissions.

createCustomRole (Lines 329-331) correctly refuses to grant permissions the creator lacks, but inviteMember only requires members.manage and then accepts any roleId — including owner. An admin (no billing.manage) or a custom role holding just members.manage can invite a principal with the full permission set, then act through it. Same gap applies to resendInvitation, which reuses prior.roleId.

🔒 Suggested guard
     this.#assertPermission(input.actorUserId, input.organizationId, "members.manage");
     if (input.workspaceId && this.#workspace(input.workspaceId).organizationId !== input.organizationId) {
       enterpriseError("TENANT_MISMATCH", "Invitation workspace belongs to another organization.");
     }
-    this.#permissionsForRole(input.organizationId, input.roleId);
+    const granted = this.#permissionsForRole(input.organizationId, input.roleId);
+    const actorPermissions = new Set(this.#effectivePermissions(input.actorUserId, input.organizationId));
+    if (granted.some((permission) => !actorPermissions.has(permission))) {
+      enterpriseError("PERMISSION_DENIED", "Invitation cannot grant a permission the inviter lacks.");
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/enterprise-platform/organizations.ts` around lines 186 - 222, Update
inviteMember and resendInvitation to validate the invited role’s permissions
against the acting user’s permissions before creating or resending an
invitation. Reuse the existing role-permission and actor-permission helpers,
ensuring roles such as owner cannot be granted unless the actor already has
every permission in that role; preserve the existing invitation flow after this
authorization check.

Comment on lines +114 to +116
importReferences(scope: ManagedScope, references: Array<{ id: string; name: string; allowedPurposes: SecretPurpose[]; createdAt: string }>): void {
for (const reference of references) this.secrets.set(reference.id, { ...clone(reference), scopeKey: scope.scopeKey, status: "rotation-required", versions: [] });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

importReferences reuses source secret ids and clobbers the source scope's entries.

this.secrets is keyed by secret id only, and the imported references carry the ids from the source environment (see exportReferences at Line 111 and the restore path in lib/managed-platform/backups.ts Line 119). Restoring a backup into a different environment of the same project therefore overwrites the source environment's StoredSecret entries in place — scopeKey is reassigned to the target and versions is emptied — so resolveForRuntime starts failing for the source environment's functions/webhooks.

Mint fresh ids on import (keying by scope + id would also work) and skip/validate collisions instead of blind set.

🐛 Proposed fix: derive per-scope keys and mint new ids
   importReferences(scope: ManagedScope, references: Array<{ id: string; name: string; allowedPurposes: SecretPurpose[]; createdAt: string }>): void {
-    for (const reference of references) this.secrets.set(reference.id, { ...clone(reference), scopeKey: scope.scopeKey, status: "rotation-required", versions: [] });
+    for (const reference of references) {
+      const id = `secret_${randomUUID()}`;
+      this.secrets.set(id, { ...clone(reference), id, scopeKey: scope.scopeKey, status: "rotation-required", versions: [] });
+    }
   }

Note that consumers holding the old reference id (e.g. signingSecretId in restored webhook configuration) will need a mapping returned from this method.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/managed-platform/secrets.ts` around lines 114 - 116, Update
importReferences to avoid reusing source secret IDs: generate a fresh ID for
each imported reference, preserve the target scope and rotation-required state,
and prevent collisions rather than blindly overwriting existing entries in
this.secrets. Return an old-to-new ID mapping from importReferences so restore
callers such as restored webhook signingSecretId references can be rewritten to
the minted IDs.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e77075ebda

ℹ️ 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".

Comment on lines +121 to +124
async function forward(request: Request, params: Promise<{ collection: string }>, method: "GET" | "POST") {
try {
if (method === "POST" && !sameOrigin(request)) return Response.json({ state: "permission-denied" }, { status: 403 });
const name = await collection(params);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Authenticate generated collection proxy requests

When a generated app is deployed with DROPS_MANAGED_PROJECT_CAPABILITY, any direct client can read an allowed collection or submit a POST without an Origin header; sameOrigin() accepts the missing header and GET receives no check at all. Because the server then attaches the project bearer capability, anonymous callers can read or mutate everything that capability permits rather than operating under caller-scoped authorization. Require an authenticated project user and propagate a scoped principal before forwarding these requests.

Useful? React with 👍 / 👎.

Comment on lines +105 to +107
collection.fields[operation.to] = collection.fields[operation.from];
delete collection.fields[operation.from];
for (const index of collection.indexes) index.fields = index.fields.map((field) => field === operation.from ? operation.to : field);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Migrate stored values when renaming fields

When a collection already contains rows and a rename-field plan is applied, these lines rename only the schema and indexes while leaving every record in state.rows under the old key. Reads therefore continue returning the undeclared field, filters on the new field miss existing data, and required-field semantics no longer match persisted records. Move each stored value to the new key atomically as part of applying the migration.

AGENTS.md reference: AGENTS.md:L81-L83

Useful? React with 👍 / 👎.

Comment on lines +112 to +113
if (collection.indexes.some((index) => index.name === operation.index.name)) throw new ManagedPlatformError("INDEX_EXISTS", "Index already exists.");
collection.indexes.push(validateCollection({ ...collection, indexes: [...collection.indexes, operation.index] }).indexes.at(-1)!);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate existing rows before adding unique indexes

When a collection already contains duplicate values, an add-index migration with unique: true succeeds because this validates only the index definition and never checks existing rows. The environment is then committed with data that already violates its declared uniqueness constraint, even though later writes are checked. Reject the migration or require a deterministic cleanup when current rows violate the new index.

AGENTS.md reference: AGENTS.md:L81-L83

Useful? React with 👍 / 👎.

Comment on lines +194 to +198
this.#assertPermission(input.actorUserId, input.organizationId, "members.manage");
if (input.workspaceId && this.#workspace(input.workspaceId).organizationId !== input.organizationId) {
enterpriseError("TENANT_MISMATCH", "Invitation workspace belongs to another organization.");
}
this.#permissionsForRole(input.organizationId, input.roleId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reserve the owner role for explicit ownership transfers

An organization admin has members.manage, so this unrestricted role lookup lets the admin invite someone with roleId: "owner"; after acceptance that member receives every permission, including billing.manage, without the current owner's confirmation or transferOwnership(). Reject owner invitations and require the existing confirmed transfer path for assigning this role.

Useful? React with 👍 / 👎.

Comment on lines +57 to +64
signCapability(scope: ManagedScope, objectId: string, operation: "read" | "delete", principal: ManagedPrincipal, options: { ttlSeconds?: number } = {}): string {
assertScope(scope, principal);
requirePermission(principal, "backend.storage.manage");
const object = this.objects.get(objectId);
if (!object || object.scopeKey !== scope.scopeKey || object.status !== "active") throw new ManagedPlatformError("OBJECT_NOT_FOUND", "Stored object does not exist.");
const ttl = options.ttlSeconds ?? 300;
if (!Number.isInteger(ttl) || ttl < 10 || ttl > 3_600) throw new ManagedPlatformError("CAPABILITY_TTL_INVALID", "Object capability lifetime is invalid.");
return signPayload({ version: 1, scopeKey: scope.scopeKey, objectId, operation, exp: Math.floor(this.options.now().getTime() / 1000) + ttl, nonce: randomUUID() }, this.options.signingKey);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Implement the issued object deletion capability

Callers can request a signed capability whose operation is delete, but ManagedObjectStorage only implements read(), which rejects anything other than a read token, and there is no deletion consumer elsewhere in the repository. Consequently stored objects can never be removed and their count and byte quotas can never be reclaimed. Either implement verified deletion that marks the object deleted or stop issuing unusable delete capabilities.

Useful? React with 👍 / 👎.

Comment on lines +368 to +372
if ([...this.subscriptions.values()].filter((entry) => entry.scopeKey === scope.scopeKey).length >= this.options.limits.maxRealtimeSubscriptions) throw new ManagedPlatformError("REALTIME_CONNECTION_LIMIT", "Realtime connection limit exceeded.");
const id = `subscription_${randomUUID()}`;
const subscription: RealtimeSubscription = { id, scopeKey: scope.scopeKey, collection: input.collection, cursor: 0, createdAt: this.options.now().toISOString() };
this.subscriptions.set(id, subscription);
return clone({ ...subscription, mode: this.mode });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release realtime subscriptions after disconnect

Every call to subscribe() permanently inserts an entry into subscriptions, but the service has no unsubscribe, expiry, or disconnect-cleanup path anywhere in the repository. After an environment creates maxRealtimeSubscriptions over its lifetime—even sequentially rather than concurrently—all future clients receive REALTIME_CONNECTION_LIMIT until the process restarts. Add explicit unsubscribe and stale-subscription cleanup before counting active connections.

Useful? React with 👍 / 👎.

Comment on lines +327 to +329
const managedImport = spec.presetId === "custom-product"
? 'import { useManagedCollection } from "../lib/use-managed-collection";'
: "";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wire managed persistence into every requesting preset

Managed backend files are enabled for any preset whose prompt requests a database, auth, jobs, realtime, or related capability, but the generated component imports and uses useManagedCollection only when presetId is custom-product. A user who asks for managed persistence while starting from any of the other eleven presets receives backend metadata that the runnable product never calls. Select and integrate the managed client from requested capabilities, with category-native data behavior for each affected preset.

AGENTS.md reference: AGENTS.md:L7-L11

Useful? React with 👍 / 👎.

key={item.href}
href={item.href}
aria-current={active === item.label ? "page" : undefined}
className={`flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-xl px-2 text-center text-xs font-semibold no-underline transition-colors focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-[#316cff]/30 sm:px-4 sm:text-sm ${

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep mobile navigation control text at 14px

At the required 390 px viewport, these navigation links use Tailwind's text-xs value (12 px); they do not become text-sm until the 640 px breakpoint. Because these are visible interactive controls, the mobile platform navigation violates the repository's 14 px control-text minimum. Use text-sm at the base breakpoint.

AGENTS.md reference: AGENTS.md:L25-L25

Useful? React with 👍 / 👎.

<div className="flex items-start justify-between gap-4"><span className="grid size-12 place-items-center rounded-2xl bg-[#eef4ff] text-[#245fe5]"><Icon className="size-6" aria-hidden="true" /></span><StatusBadge status={status}>{label}</StatusBadge></div>
<p className="mt-6 text-xs font-bold uppercase tracking-[0.12em] text-[#245fe5]">{integration.eyebrow}</p>
<h2 className="mt-2 text-xl font-semibold tracking-[-0.03em]">{integration.name}</h2>
<p className="mt-2 flex-1 text-sm leading-6 text-[#52617a]">{integration.description}</p>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Raise public integration body copy to 16px

Integration descriptions are ordinary body copy on a public catalog, but text-sm renders them at 14 px across all viewports. This falls below the repository's mandatory 16–18 px body-text range and makes the newly added catalog systematically undersized. Render these descriptions at text-base or larger.

AGENTS.md reference: AGENTS.md:L24-L24

Useful? React with 👍 / 👎.

Comment on lines +97 to +103
writeBranchFile(input: { branchId: string; path: string; content: string }): AiTaskBranch {
const branch = this.#openBranch(input.branchId);
const path = normalizeProjectPath(input.path);
if (!branch.taskScope.some((scope) => matchesScope(path, scope))) enterpriseError("BRANCH_SCOPE_DENIED", "AI branch write is outside its assigned scope.");
if (input.content.length > 1_000_000 || input.content.includes("\0")) enterpriseError("INVALID_INPUT", "Branch file content is invalid.");
branch.files[path] = input.content;
branch.updatedAt = iso(this.#runtime.now());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the project file limit on branch writes

The 5,000-file limit is enforced only when a project is initially created; repeated scoped calls to writeBranchFile() can add unlimited new paths, and a later merge installs that oversized map into the canonical project. This bypasses the branch manager's declared file-count bound and can create projects that downstream validators or persistence cannot safely handle. Reject a write that would add a new path beyond the limit and recheck the bound before merge.

AGENTS.md reference: AGENTS.md:L81-L83

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant