Skip to content

feat(web): project cookie transport and list/create data layer - #23

Open
marcorivm wants to merge 1 commit into
feat/projects-createfrom
feat/projects-switch-transport
Open

feat(web): project cookie transport and list/create data layer#23
marcorivm wants to merge 1 commit into
feat/projects-createfrom
feat/projects-switch-transport

Conversation

@marcorivm

Copy link
Copy Markdown
Member

Slice 3a of #20, stacked on #22. 5 files, ~90 lines. No visible UI.

Split from the switcher UI deliberately: this adds a new source of project scope on every request, which deserves reviewing on its own rather than buried under components.

The problem it solves

proxy.ts strips the /p/<id> prefix when !CAPS.orgScopedUI — so on oss and onprem-slim the URL carries no project scope at all, and the only sources are the /app-connect query bridge and the findUserDefaultProject fallback. A switcher on those editions has nothing to ride on. DEFAULT_PROJECT_COOKIE is that transport; the proxy turns it into the X-Project-Id header the API already understands.

Why this is safe

The cookie is a hint, never authority. resolveProjectId already validates the id against the caller's active memberships and canAccessProjectAsUser before trusting it — so a forged or stale cookie resolves to no project, not to someone else's. That's why applying it on every edition is fine: it only ever sets a header that is independently re-validated downstream.

Precedence is path → query → cookie, cookie last:

const projectId =
  pathname.match(PROJECT_PATH_RE)?.[1] ||
  (fromQuery ? searchParams.get("projectId") : null) ||
  request.cookies.get(DEFAULT_PROJECT_COOKIE)?.value;

An explicit /p/<id> or ?projectId= must always beat a stale selection. On flat editions the first two never match, so the cookie is the only source — which is the point.

SameSite=Lax, no Secure flag: self-hosted installs are routinely plain HTTP on a private network, and a Secure cookie would silently never be set there.

Upstream alignment

Named and shaped to sit beside upstream's DEFAULT_ORG_COOKIE, which lands in this same file in v1.45.0 — one definition each so writer and reader can't drift, per upstream's own comment on theirs. When the v1.45.0 adoption lands, the two live together and slice 4 (org switching) inherits the pattern.

projects.list() takes no org parameter — the org comes from the scope the proxy already sets. Upstream widens apiGet with an X-Organization-Id override in v1.45.0 for its account-route picker; client.ts is deliberately left untouched here, since it's one of that release's six conflict files and churning it would make the adoption harder.

What's here

  • navigation.tsDEFAULT_PROJECT_COOKIE + read/write helpers
  • proxy.ts — cookie → x-project-id
  • api/projects.tslist(), create(name)
  • api/keys.tsqueryKeys.projects.list()
  • hooks/use-projects.tsuseProjectsList, useCreateProject (invalidates the list on success)

Tests

None, and that's a deliberate gap. apps/web has no vitest harness on the 1.44.0 base — upstream ships apps/web/vitest.config.ts in v1.45.0 (flagged as a "take" in the sync review). Adding one here would collide with that adoption. The cookie helpers are pure functions and are the obvious first thing to cover once the harness arrives.

pnpm run check is clean (lint + types + format).

Slice 3a of docs/project-lifecycle.md — no visible UI, just what the switcher
needs to exist. Split from the UI so the security-relevant part (a new source
of project scope on every request) can be reviewed on its own.

Flat editions have no transport for a switcher: proxy.ts strips the /p/<id>
prefix when !CAPS.orgScopedUI, so the URL carries no project scope and the
only sources are the /app-connect query bridge and the default-project
fallback. DEFAULT_PROJECT_COOKIE is that transport — the proxy turns it into
the X-Project-Id header the API already understands.

Precedence is path > query > cookie. The cookie is last so an explicit
/p/<id> or ?projectId= always beats a stale selection, and on flat editions
it is the only source there is.

The cookie is a HINT, never authority. resolveProjectId still validates the
id against the caller's memberships and canAccessProjectAsUser before
trusting it, so a forged or stale value resolves to no project rather than to
someone else's. Applying it on every edition is therefore safe: it only ever
SETS a header that is independently re-validated.

Named and shaped to sit beside upstream's DEFAULT_ORG_COOKIE, which lands in
this same file in v1.45.0 — one definition each so writer and reader cannot
drift.

projects.list() takes no org parameter: the org comes from the scope the
proxy already sets. Upstream widens apiGet with an X-Organization-Id override
in v1.45.0 for its account-route picker; client.ts is deliberately left
untouched here since it is one of that release's conflict files.

No tests: apps/web has no vitest harness on 1.44.0, and adding one would
collide with the config upstream ships in v1.45.0.
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