-
Notifications
You must be signed in to change notification settings - Fork 53
feat(web): one sidebar, one picker, one scope — and a home that shows the work #6801
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 c19a80c
feat(api): GLOBAL_SEARCH covers task-board cards
tlgimenes c34b47a
refactor(web): collapse the loading cascade to two states
tlgimenes f4df28f
feat(web): one sidebar, one picker, one scope
tlgimenes f5f1dc9
feat(web): the org home is the organization's agent roster
tlgimenes b2be51b
feat(web): one flat Connect page, clients then API keys
tlgimenes afd6c3c
feat(web): Site Editor, Content and Code are one surface
tlgimenes d088cf2
feat(web): a release-driven layout tour, scoped to the screen you are on
tlgimenes be059c6
refactor: remove the org "main agent"
tlgimenes 06ff908
refactor(ui): one spinner
tlgimenes 2a88297
fix(web): scale up the boot splash mark
tlgimenes 24ffe5f
feat(web): an org feed of finished work, and a sidebar that says what…
rafavalls fe6794e
test: follow the Agent→Project rename and the tile-board removal
tlgimenes 4040d73
fix(web): give the org/project feed the board's live path
tlgimenes 2855004
fix(web): the project home's feed carries the work you just created
tlgimenes 2a40672
fix(web): anchor the feed's tour step on its heading, not its cards
tlgimenes ed7c6bb
fix: nine defects from the max review
tlgimenes 940a6ff
refactor: stop reading and writing main_agent_id, but keep the column
tlgimenes b885897
refactor(web): the feed is the board's list view, on both homes
tlgimenes a7a7108
fix(web): the boot gate fails open, and the auth layout has three states
tlgimenes 6452933
chore(web): delete 186 orphaned translation keys
tlgimenes ba25f99
fix(web): the auth gate is one switch, not three overlapping predicates
tlgimenes 2831f77
fix(web): close the two CodeQL alerts in the registry views
tlgimenes 4205db9
fix(web): sanitize the registry image preview with the shared helper
tlgimenes File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
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) => { | ||
|
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; | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.liveAgentIdis never detected becausesearchAcrossMembershipsreturns the text column as a string. Parseconnections.metadatabeforecreateMeRoutesfilters 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
There was a problem hiding this comment.
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 TEXTmetadatacolumn before returning, socreateMeRoutesfilters on an object rather than a string. Parsing at the query keeps every consumer ofCrossOrgProjectMatchcorrect rather than only this caller.