Skip to content

feat(weaverse): per-request projectId resolver + 24h cookie for Cloud Sandbox#383

Closed
paul-phan wants to merge 1 commit into
mainfrom
feat/sandbox-multiproject-resolver
Closed

feat(weaverse): per-request projectId resolver + 24h cookie for Cloud Sandbox#383
paul-phan wants to merge 1 commit into
mainfrom
feat/sandbox-multiproject-resolver

Conversation

@paul-phan
Copy link
Copy Markdown
Member

Summary

Phase 1.5 Slice 1.5c of Weaverse/builder#2222 (Cloud Sandbox).

A single sandbox machine serves multiple projects (M:N preview model from builder#2326). The Weaverse Hydrogen SDK already resolves ?weaverseProjectId=<id> from the URL query with highest priority — the missing piece is that once the user clicks an in-iframe link and the query string disappears, the SDK falls through to env.WEAVERSE_PROJECT_ID and the wrong theme renders.

This PR adds a tiny cookie bridge: ~95 LOC, 3 files, zero SDK changes.

How it fits the SDK's existing priority chain

@weaverse/hydrogen's projectId resolution (already shipping in v5.13+):

  1. ?weaverseProjectId=<id> URL param   ← Studio iframe ALWAYS sets this
  2. projectId function arg              ← THIS PR plugs in here
  3. projectId string arg
  4. env.WEAVERSE_PROJECT_ID             ← bare URL fallback

Slot 2 was unused. We fill it with () => readProjectIdFromCookie(request). Cookie is set on the way out whenever slot 1 fires, so in-iframe navigation after the initial load (which loses the query string) still resolves the same project.

Files

File What
app/lib/weaverse/resolve-project.server.ts (new) readProjectIdFromCookie + maybeSetProjectIdCookie. ~75 LOC.
app/.server/context.ts Pass projectId: () => readProjectIdFromCookie(request) to WeaverseClient.
server.ts After response, call maybeSetProjectIdCookie(request, response).

Why the call is AFTER session.commit()

This is the subtle bit. server.ts already does:

response.headers.set("Set-Cookie", await hydrogenContext.session.commit())

.set() (not .append()) clobbers any existing Set-Cookie header. If I .append() my project cookie before that line, it gets nuked.

So the order is:

  1. handleRequest → response built
  2. session.commit() → session cookie .set()
  3. maybeSetProjectIdCookie → project cookie .append()

Both cookies ride along on the same response. There's an inline comment in server.ts documenting this so a future refactor doesn't reorder them.

Security

  • Cookie value validated against /^[a-zA-Z0-9_-]{1,128}$/ on both read and write paths. Poisoned cookies silently return '', falling through to env.
  • Cookie flags: Max-Age=86400; Path=/; SameSite=Lax; Secure; HttpOnly
    • Lax over Strict: cookie is for the sandbox origin (<handle>.weaverse.dev) which IS top-level when the SDK reads it. Strict would break in-iframe nav.
    • HttpOnly: not strictly needed (we never read client-side) but defense-in-depth.
  • Studio iframe always sets the explicit ?weaverseProjectId= query param when loading (per Phase 1.5 spec). Cookie is a fallback for in-iframe nav only, never trusted cross-tab.

Test plan

  • npm run typecheck — 1 pre-existing error (bun cache duplicate @shopify/hydrogen versions, unrelated, present on bare main), 0 new.
  • npm run biome — 3 useBlockStatements style warnings, exit 0.
  • Smoke test (manual, post-merge): load <handle>.weaverse.dev/?weaverseProjectId=A, click an in-iframe link, confirm the response still uses project A (not env default). Then load <handle>.weaverse.dev/?weaverseProjectId=B, confirm cookie is updated and B renders.

Why no unit tests

Pilot uses Playwright e2e only; no unit-test runner is configured. The module is small (regex + cookie parse + Set-Cookie header build). Real traffic from Phase 1.5d onward will exercise it within a few days of merge. Adding a Vitest setup just for this would be scope creep.

If you want me to add a Vitest setup anyway, happy to in a follow-up — but for one 75 LOC module on a tight schedule, the cost-benefit doesn't pencil out.

Rollout

Deploys to the weaverse-sandbox Pilot theme image. Once merged, the next bun run build-theme.sh in weaverse-sandbox picks it up and bakes it into a new theme image digest. Then the next sandbox machine boot uses it. Existing running sandboxes pick it up on next image refresh.

No breaking changes for non-sandbox Pilot deployments — the cookie is only ever read when set, and only ever set when the query param is present.

Refs Weaverse/builder#2222 Phase 1.5 Slice 1.5c. Spec: Weaverse/builder/.specs/2026-05-12--cloud-sandbox-phase-1/README.md.

Stacks logically on:

Independent at the git level — this can merge any time.

Phase 1.5 Slice 1.5c of Weaverse/builder#2222 (Cloud Sandbox).

A single sandbox machine serves multiple projects (M:N from Slice
1.5a). The Weaverse Hydrogen SDK already resolves `?weaverseProjectId=`
from the URL query with highest priority \u2014 the missing piece is that
once the user clicks an in-iframe link and the query string is gone,
the SDK falls through to env.WEAVERSE_PROJECT_ID and the wrong theme
renders.

THIS PR adds a tiny cookie bridge:

  app/lib/weaverse/resolve-project.server.ts  (NEW, ~75 LOC)
    readProjectIdFromCookie(request) -> string
      Validates against the same alphanumeric/hyphen/underscore
      pattern the SDK uses internally (rejects poisoned cookies
      that could try header injection or other shenanigans).
      Returns '' on miss so the SDK keeps falling through to env.

    maybeSetProjectIdCookie(request, response) -> void
      If URL has ?weaverseProjectId=<id> with a valid value, appends
      a Set-Cookie header to the response with:
        - Max-Age: 24h
        - SameSite=Lax  (Strict would break in-iframe nav)
        - Secure + HttpOnly
        - Path=/

  app/.server/context.ts
    Pass `projectId: () => readProjectIdFromCookie(request)` to
    WeaverseClient. The SDK's priority chain handles the rest:
      1. ?weaverseProjectId= URL param  (Studio iframe ALWAYS sets this)
      2. our cookie function           (in-iframe nav fallback)
      3. env.WEAVERSE_PROJECT_ID       (bare URL fallback)

  server.ts
    After handleRequest returns AND after session.commit(), call
    maybeSetProjectIdCookie(request, response).

    Order matters: session.commit() writes Set-Cookie via .set() (not
    .append()), which would clobber a cookie written earlier. By
    running our .append() afterward, both the session cookie and the
    project cookie ride along on the same response. Comment in code
    documents this so a future refactor doesn't reorder them.

ZERO SDK CHANGES \u2014 verified @weaverse/hydrogen v5.13+ already accepts
`projectId: string | (() => string) | (() => Promise<string>)` and
runs the priority chain server-side.

SECURITY NOTES

  - Cookie value is validated with /^[a-zA-Z0-9_-]{1,128}$/ on BOTH
    read and write paths. Poisoned cookies are silently ignored
    (return '').
  - HttpOnly: not needed for our usage (we never read this cookie
    client-side) but defense-in-depth against XSS-induced exfiltration.
  - SameSite=Lax: chosen over Strict because the cookie is for the
    sandbox origin (<handle>.weaverse.dev), which IS the top-level
    when the SDK reads it. The Studio iframe lives on
    studio.weaverse.io so its cross-origin status is irrelevant \u2014
    the cookie is set/read on its own domain.
  - Studio iframe ALWAYS sets the explicit query param when loading
    (per Phase 1.5 spec rule). The cookie is a fallback for
    in-iframe navigation only, never trusted cross-tab.

VERIFICATION

  npm run typecheck   1 pre-existing error (bun cache duplicate
                      @shopify/hydrogen versions, unrelated, present
                      on bare main), 0 new
  npm run biome       3 useBlockStatements style warnings (biome
                      exits 0), 0 errors

NO UNIT TESTS

  Pilot uses Playwright e2e only; no unit-test runner is set up.
  The resolver is small (regex + cookie parse + Set-Cookie header
  build) and will be exercised by real traffic from Phase 1.5d
  onward. Adding a Vitest setup just for this module would be
  scope creep.

Spec: .specs/2026-05-12--cloud-sandbox-phase-1/README.md in
Weaverse/builder, section 'Phase 1.5 Slice 1.5c'.
Stacks logically on Weaverse/builder PRs #2326 (schema) and
#2327 (API). Independent at the git level \u2014 deploys to the
weaverse-sandbox container and goes live as soon as merged + new
theme image is built.
@paul-phan paul-phan closed this May 13, 2026
@paul-phan paul-phan deleted the feat/sandbox-multiproject-resolver branch May 13, 2026 02:30
@paul-phan
Copy link
Copy Markdown
Member Author

Closing \u2014 moving the implementation into @weaverse/hydrogen instead.

Pilot is a starter template that customers fork and maintain on their own schedule; we can't ship cookie + resolver changes here without risking breakage in downstream forks. The proper place for multi-project preview support is the SDK package itself, where:

  1. Old themes keep working unchanged (no behavior change for non-sandbox setups).
  2. New themes get cookie-based projectId resolution automatically after bumping @weaverse/hydrogen.
  3. The cookie WRITE side becomes a single opt-in helper for themes that want sandbox-iframe nav persistence \u2014 documented but not required.

New PR coming on the Weaverse monorepo SDK. Will link back here.

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