Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2783ce7
feat(api): cross-organization project search, fenced to the caller's …
tlgimenes Sep 2, 2026
c19a80c
feat(api): GLOBAL_SEARCH covers task-board cards
tlgimenes Sep 2, 2026
c34b47a
refactor(web): collapse the loading cascade to two states
tlgimenes Sep 2, 2026
f4df28f
feat(web): one sidebar, one picker, one scope
tlgimenes Sep 2, 2026
f5f1dc9
feat(web): the org home is the organization's agent roster
tlgimenes Sep 2, 2026
b2be51b
feat(web): one flat Connect page, clients then API keys
tlgimenes Sep 2, 2026
afd6c3c
feat(web): Site Editor, Content and Code are one surface
tlgimenes Sep 2, 2026
d088cf2
feat(web): a release-driven layout tour, scoped to the screen you are on
tlgimenes Sep 2, 2026
be059c6
refactor: remove the org "main agent"
tlgimenes Sep 2, 2026
06ff908
refactor(ui): one spinner
tlgimenes Sep 2, 2026
2a88297
fix(web): scale up the boot splash mark
tlgimenes Sep 2, 2026
24ffe5f
feat(web): an org feed of finished work, and a sidebar that says what…
rafavalls Sep 2, 2026
fe6794e
test: follow the Agent→Project rename and the tile-board removal
tlgimenes Sep 2, 2026
4040d73
fix(web): give the org/project feed the board's live path
tlgimenes Sep 3, 2026
2855004
fix(web): the project home's feed carries the work you just created
tlgimenes Sep 3, 2026
2a40672
fix(web): anchor the feed's tour step on its heading, not its cards
tlgimenes Sep 3, 2026
ed7c6bb
fix: nine defects from the max review
tlgimenes Sep 3, 2026
940a6ff
refactor: stop reading and writing main_agent_id, but keep the column
tlgimenes Sep 3, 2026
b885897
refactor(web): the feed is the board's list view, on both homes
tlgimenes Sep 3, 2026
a7a7108
fix(web): the boot gate fails open, and the auth layout has three states
tlgimenes Sep 3, 2026
6452933
chore(web): delete 186 orphaned translation keys
tlgimenes Sep 3, 2026
ba25f99
fix(web): the auth gate is one switch, not three overlapping predicates
tlgimenes Sep 3, 2026
2831f77
fix(web): close the two CodeQL alerts in the registry views
tlgimenes Sep 3, 2026
4205db9
fix(web): sanitize the registry image preview with the shared helper
tlgimenes Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 6 additions & 0 deletions apps/api/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
import { posthog } from "../posthog";
import authRoutes from "./routes/auth";
import desktopAuthRoutes from "./routes/desktop-auth";
import { ME_API_PREFIX, createMeRoutes } from "./routes/me";
import {
ADMIN_API_PREFIX,
createAdminRoutes,
Expand Down Expand Up @@ -2230,6 +2231,11 @@ export async function createApp(options: CreateAppOptions = {}) {
// Storefront "." shortcut: resolve (site, domain) → editor. Instance-level (org from org_sites), so it must win over `:org` below.
app.route("/api/_editor-resolve", createEditorResolveRoutes());

// User-scoped, cross-organization reads (project search). Instance-level for
// the same reason as the two above: `/api/:org` would bind the request to one
// tenant, which is exactly what these routes must not do.
app.route(ME_API_PREFIX, createMeRoutes());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: When this mounted search is used, metadata.liveAgentId is never detected because searchAcrossMemberships returns the text column as a string. Parse connections.metadata before createMeRoutes filters and serializes the results, otherwise hidden dev agents appear in the picker and metadata consumers receive a string instead of an object.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/api/app.ts, line 2216:

<comment>When this mounted search is used, `metadata.liveAgentId` is never detected because `searchAcrossMemberships` returns the text column as a string. Parse `connections.metadata` before `createMeRoutes` filters and serializes the results, otherwise hidden dev agents appear in the picker and metadata consumers receive a string instead of an object.</comment>

<file context>
@@ -2209,6 +2210,11 @@ export async function createApp(options: CreateAppOptions = {}) {
+  // User-scoped, cross-organization reads (project search). Instance-level for
+  // the same reason as the two above: `/api/:org` would bind the request to one
+  // tenant, which is exactly what these routes must not do.
+  app.route(ME_API_PREFIX, createMeRoutes());
+
   // New canonical org-scoped API surface — all routes that depend on org context
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same root cause as the storage-layer comment: fixed in searchAcrossMemberships, which now parses the TEXT metadata column before returning, so createMeRoutes filters on an object rather than a string. Parsing at the query keeps every consumer of CrossOrgProjectMatch correct rather than only this caller.


// New canonical org-scoped API surface — all routes that depend on org context
// live here. Old routes still work (with deprecation logs) until the cleanup
// PR removes them after the deprecation window.
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/api/middleware/resolve-org-from-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function isPublicSharePath(c: Context): boolean {
* Legacy/internal keys without that field are left to their existing route
* authorization rules; a malformed explicit organization binding fails closed.
*/
function getApiKeyOrganizationBinding(ctx: StudioContext): {
export function getApiKeyOrganizationBinding(ctx: StudioContext): {
present: boolean;
id?: string;
} {
Expand Down
12 changes: 8 additions & 4 deletions apps/api/src/api/routes/commerce-diagnostic-share.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ import {
describe("diagnosticDeepLinkPath", () => {
it("builds the deep link that opens the diagnostic app view", () => {
const path = diagnosticDeepLinkPath("acme", "org_123");
// Relative (safe redirectTo): agent and view are path, its params are search.
expect(
path.startsWith("/acme/agents/commerce-discovery_org_123/app?"),
).toBe(true);
// Relative (safe redirectTo): the VIEW is the path, everything else search.
expect(path.startsWith("/acme/agents/app?")).toBe(true);
const search = new URLSearchParams(path.slice(path.indexOf("?") + 1));
/** `virtualmcpid`, NOT `project`: the router declares only the former, and
* its search schema strips unknown keys — so a link minted with `project`
* silently opened on the Super Agent. These URLs are persisted and mailed,
* so the key is a wire contract. */
expect(search.get("virtualmcpid")).toBe("commerce-discovery_org_123");
expect(search.get("project")).toBeNull();
expect(search.get("connection")).toBe("org_123_commerce-discovery");
expect(search.get("tool")).toBe("get_my_diagnostic");
});
Expand Down
14 changes: 8 additions & 6 deletions apps/api/src/api/routes/commerce-diagnostic-share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
getCommerceDiscoveryAgentId,
WellKnownOrgMCPId,
} from "@decocms/shared/sdk";
import { agentPanelPath } from "@decocms/shared/organization-paths";
import { Hono } from "hono";
import { sql } from "kysely";
import { z } from "zod";
Expand Down Expand Up @@ -59,13 +60,14 @@ export const shareInviteBodySchema = z.object({
* Relative (path + query) so it is a safe `redirectTo` (login.tsx rejects
* absolute/protocol-relative targets). Exported for unit tests. */
export function diagnosticDeepLinkPath(orgSlug: string, orgId: string): string {
const connectionId = WellKnownOrgMCPId.COMMERCE_DISCOVERY(orgId);
const search = new URLSearchParams({
connection: connectionId,
tool: COMMERCE_DISCOVERY_REPORT_TOOL_NAME,
return agentPanelPath(orgSlug, {
projectId: getCommerceDiscoveryAgentId(orgId),
panel: "app",
search: {
connection: WellKnownOrgMCPId.COMMERCE_DISCOVERY(orgId),
tool: COMMERCE_DISCOVERY_REPORT_TOOL_NAME,
},
});
const agentId = getCommerceDiscoveryAgentId(orgId);
return `/${orgSlug}/agents/${agentId}/app?${search.toString()}`;
}

export const createCommerceDiagnosticShareRoutes = () => {
Expand Down
84 changes: 84 additions & 0 deletions apps/api/src/api/routes/me.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from "bun:test";
import type { StudioContext } from "@/core/studio-context";
import { credentialOrganizationFence, DENY } from "./me";

/** Build just the auth slice the fence reads. */
function ctx(auth: unknown): StudioContext {
return { auth } as unknown as StudioContext;
}

describe("credentialOrganizationFence", () => {
it("lets a session user read every organization they belong to", () => {
expect(credentialOrganizationFence(ctx({ user: { id: "u1" } }))).toBeNull();
});

it("confines an org-bound API key to the organization that minted it", () => {
const fence = credentialOrganizationFence(
ctx({
user: { id: "u1" },
apiKey: { id: "k1", metadata: { organization: { id: "org_a" } } },
}),
);
expect(fence).toBe("org_a");
});

it("confines an org-scoped token to its organization", () => {
expect(
credentialOrganizationFence(
ctx({ user: { id: "u1" }, tokenOrganizationId: "org_b" }),
),
).toBe("org_b");
});

/** The same fail-closed rule resolveOrgFromPath applies: an explicit but
* unreadable binding must not fall through to the unfenced path. */
it("denies an API key whose organization binding is present but malformed", () => {
expect(
credentialOrganizationFence(
ctx({
user: { id: "u1" },
apiKey: { id: "k1", metadata: { organization: 42 } },
}),
),
).toBe(DENY);
expect(
credentialOrganizationFence(
ctx({
user: { id: "u1" },
apiKey: { id: "k1", metadata: { organization: { id: 7 } } },
}),
),
).toBe(DENY);
});

it("denies when a key and a token name different organizations", () => {
expect(
credentialOrganizationFence(
ctx({
user: { id: "u1" },
apiKey: { id: "k1", metadata: { organization: { id: "org_a" } } },
tokenOrganizationId: "org_b",
}),
),
).toBe(DENY);
});

/** INVERTED. A legacy key carrying no organization binding used to fall
* through as `null` — unfenced — which on a route that answers across every
* membership means a key minted for one org enumerating its owner's other
* orgs. A key is a scoped credential; absence of a scope is not consent to
* all of them. */
it("denies a key with no organization binding, rather than unfencing it", () => {
expect(
credentialOrganizationFence(
ctx({ user: { id: "u1" }, apiKey: { id: "k1", metadata: {} } }),
),
).toBe(DENY);
});

/** A SESSION is the person, and this route is that person's own data across
* their memberships — so no binding here means no fence, as before. */
it("leaves a session caller unfenced", () => {
expect(credentialOrganizationFence(ctx({ user: { id: "u1" } }))).toBeNull();
});
});
182 changes: 182 additions & 0 deletions apps/api/src/api/routes/me.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* User-scoped API — the caller's data across every org they belong to.
*/

import { Hono } from "hono";
import { isDecopilot, isStudioPackAgent } from "@decocms/shared/sdk";
import { isOrgArchived } from "@decocms/shared/organization/org-archived";
import { getApiKeyOrganizationBinding } from "../middleware/resolve-org-from-path";
import { getUserId, type StudioContext } from "@/core/studio-context";
import type { CrossOrgProjectMatch } from "@/storage/virtual";

export const ME_API_PREFIX = "/api/_me";

type MeEnv = { Variables: { studioContext: StudioContext } };

/** Rows the picker must never offer. Each test is per-row, so the predicate
* survives a search that spans organizations — unlike the web client's dev
* filter, which infers "is a dev agent" by scanning one org's whole list and
* therefore cannot run over a cross-org result set. */
function isPlumbing(match: CrossOrgProjectMatch): boolean {
if (isDecopilot(match.id) !== null) return true;
if (isStudioPackAgent(match.id)) return true;
const liveAgentId = match.metadata?.liveAgentId;
return typeof liveAgentId === "string" && liveAgentId.length > 0;
}

/**
* Drops rows from organizations whose SSO the caller has not completed.
*/
function createSsoGate(ctx: StudioContext, userId: string) {
const verdicts = new Map<string, Promise<boolean>>();

const isAllowed = (orgId: string): Promise<boolean> => {
const cached = verdicts.get(orgId);
if (cached) return cached;
const verdict = (async () => {
const config = await ctx.storage.orgSsoConfig.getByOrgId(orgId);
if (!config?.enforced) return true;
return await ctx.storage.orgSsoSessions.isValid(userId, orgId);
})();
verdicts.set(orgId, verdict);
return verdict;
};

return async <T extends { orgId: string }>(rows: T[]): Promise<T[]> => {
const allowed = await Promise.all(rows.map((row) => isAllowed(row.orgId)));
return rows.filter((_, index) => allowed[index] === true);
};
}

/**
* The one organization a credential-authenticated caller may read, or `null`
* for a session user, who may read every organization they belong to.
*/
export const DENY = Symbol("deny");
export function credentialOrganizationFence(
ctx: StudioContext,
): string | null | typeof DENY {
const binding = ctx.auth?.apiKey?.id
? getApiKeyOrganizationBinding(ctx)
: { present: false as const, id: undefined };
if (binding.present && !binding.id) return DENY;

const token = ctx.auth?.tokenOrganizationId;
if (binding.id && token && binding.id !== token) return DENY;
const fence = binding.id ?? token ?? null;

/** An API key with no organization on it is NOT a licence to roam. This
* route answers across every org the caller belongs to, which is right for
* a session — that is the person's own data — but a key is a scoped
* credential, and a legacy one minted before the binding existed would
* otherwise enumerate every org its owner ever joined. Fail closed. */
if (fence === null && ctx.auth?.apiKey?.id) return DENY;
return fence;
}

/** Matches `GLOBAL_SEARCH`'s query cap. */
const MAX_TERM_LENGTH = 256;

/** Enough to draw a row and navigate to it. */
interface ProjectSearchHit {
id: string;
title: string;
icon: string | null;
orgId: string;
orgName: string;
orgSlug: string;
}

/** The picker shows a short list; a bigger page would only widen the scan. */
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
/** Over-fetch so rows dropped below don't shorten the visible page. */
const FILTER_HEADROOM = 10;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
/** Headroom alone truncated the answer whenever more rows than that were
* hidden (plumbing, archived, SSO), so the handler pages on until the page is
* full — bounded, so a term matching mostly hidden rows costs a fixed number
* of queries rather than a scan. */
const MAX_PAGES = 5;

export const createMeRoutes = () => {
const app = new Hono<MeEnv>();

/**
* `GET /api/_me/projects/search?q=<term>&limit=<n>` Projects matching `q`
* across every organization the caller belongs to.
*/
app.get("/projects/search", async (c) => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const ctx = c.get("studioContext");
// Per-caller results: no intermediary may reuse them for another session.
c.header("Cache-Control", "private, no-store");

// An API-key caller has no `auth.user`, but the key names its owner.
const userId = getUserId(ctx);
if (!userId) {
return c.json({ error: "Unauthorized" }, 401);
}

const fence = credentialOrganizationFence(ctx);
if (fence === DENY) {
return c.json(
{ error: "forbidden: credential is scoped to another organization" },
403,
);
}

const term = (c.req.query("q") ?? "").trim();
if (!term) {
return c.json({ items: [] as ProjectSearchHit[] });
}
/** Same cap as `GLOBAL_SEARCH`'s `InputSchema.query`, for the same reason:
* `%term%` is un-indexable, this runs it across every member org, and the
* refill loop re-runs it per page. Reject rather than truncate — a
* silently shortened search answers a question nobody asked. */
if (term.length > MAX_TERM_LENGTH) {
return c.json({ error: "query too long" }, 400);
}

const requested = Number.parseInt(c.req.query("limit") ?? "", 10);
const limit =
Number.isFinite(requested) && requested > 0
? Math.min(requested, MAX_LIMIT)
: DEFAULT_LIMIT;

const dropSsoBlockedOrgs = createSsoGate(ctx, userId);
const pageSize = limit + FILTER_HEADROOM;
const hits: ProjectSearchHit[] = [];

for (let page = 0; page < MAX_PAGES && hits.length < limit; page++) {
const matches = await ctx.storage.virtualMcps.searchAcrossMemberships({
userId,
term,
limit: pageSize,
offset: page * pageSize,
organizationId: fence,
});

const visible: ProjectSearchHit[] = matches
.filter((match) => !isPlumbing(match))
.filter(
(match) => !isOrgArchived({ metadata: match.organization_metadata }),
)
.map((match) => ({
id: match.id,
title: match.title,
icon: match.icon,
orgId: match.organization_id,
orgName: match.organization_name,
orgSlug: match.organization_slug,
}));

hits.push(...(await dropSsoBlockedOrgs(visible)));

// Short page: the query is exhausted, so paging on reads nothing.
if (matches.length < pageSize) break;
}

return c.json({ items: hits.slice(0, limit) });
});

return app;
};
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@ describe("OrganizationSettingsStorage — flags bag", () => {

it("updating an unrelated field leaves flags untouched", async () => {
await storage.upsert("org_1", { flags: { demo_mode: true } });
await storage.upsert("org_1", { main_agent_id: "vmcp-1" });
await storage.upsert("org_1", { enabled_plugins: ["p1"] });
const got = await storage.get("org_1");
expect(got?.flags).toEqual({ demo_mode: true });
expect(got?.main_agent_id).toBe("vmcp-1");
expect(got?.enabled_plugins).toEqual(["p1"]);
});

it("reads null when no flag was ever set", async () => {
await storage.upsert("org_1", { main_agent_id: "vmcp-1" });
await storage.upsert("org_1", { enabled_plugins: ["p1"] });
expect((await storage.get("org_1"))?.flags).toBeNull();
});
});
Loading
Loading