Skip to content

feat(owletto-backend): public-org read access + self-serve join#296

Merged
buremba merged 2 commits into
mainfrom
feat/public-org-join
Apr 21, 2026
Merged

feat(owletto-backend): public-org read access + self-serve join#296
buremba merged 2 commits into
mainfrom
feat/public-org-join

Conversation

@buremba
Copy link
Copy Markdown
Member

@buremba buremba commented Apr 21, 2026

Summary

  • New public REST endpoints for non-member views of a public workspace: GET /api/:orgSlug/public/{organization,agents,events}. SSE filters invalidation events to a public-safe key allowlist.
  • POST /api/:orgSlug/join for self-serve membership. Session-gated, rate-limited (10/hr/IP), idempotent. Mirrors Better Auth's afterAddMember side effects (ensureMemberEntity + cache invalidation).
  • New join_organization MCP tool, visible on scoped and unscoped sessions. Read-only hint so read-scoped OAuth can still call it. Short-circuits the non-member write gate in executeTool.
  • Updated write-denial errors in checkToolAccess to surface join_organization and the web join URL, so the LLM has a path forward.

Why

Signed-in users reaching a public organization's URL previously 403'd on every member-scoped endpoint — Better Auth org lookup, member list, events SSE, notifications — because REST and MCP treated non-members as unauthorized. There was also no path to become a member short of an owner invitation.

Scope

Backend only. Frontend changes (membership hook, JoinWorkspaceBanner component, login intent=join flow, dashboard/sidebar/notifications gating) ship as a separate submodule PR in lobu-ai/owletto-web + a parent bump PR here, per the two-PR submodule rule.

Test plan

  • bun run typecheck passes across the repo
  • make build-packages passes
  • New integration test packages/owletto-backend/src/__tests__/integration/mcp/public-org-join.test.ts covers: anonymous public REST reads, join happy path + idempotency, 401/403/404 variants, MCP write-denial message mentions join_organization, MCP join_organization flips manage_entity:create from denied to allowed, read-only scope note
  • Manual: non-member signed-in user hits a public org, sees read-only dashboard, clicks join, gains write access

Signed-in users reaching a public organization's URL previously 403'd on
every member-scoped endpoint (Better Auth org lookup, member list, events
SSE, notifications) because the REST and MCP layers treated non-members
as unauthorized. There was also no path to become a member short of an
owner invitation.

This lands the backend half of the fix:

- New public REST endpoints (`GET /api/:orgSlug/public/{organization,agents,events}`)
  using the existing `withPublicOrg` helper. Sanitized payloads only — no
  members, no credentials, no internal config. SSE filters invalidation
  events through a public-safe allowlist (resolve-path, entity-types,
  view-template-history, contents-filtered).
- `POST /api/:orgSlug/join` for self-serve membership. Session-gated,
  rate-limited (10/hour/IP), idempotent. Calls a shared
  `joinPublicOrganization` helper that inserts a member row with
  role='member' and mirrors Better Auth's `afterAddMember` side effects
  (ensureMemberEntity + invalidateMembershipRoleCache).
- MCP `join_organization` tool (register in both scoped and unscoped
  sessions; read-only hint so read-scoped OAuth can still call it).
  Short-circuited in `executeTool` to bypass the non-member write gate.
- Updated write-denial errors in `checkToolAccess` to surface the
  `join_organization` tool and the web join URL, so the LLM knows the
  path forward rather than dead-ending on "not a member".

Frontend changes (`packages/owletto-web`) ship as a separate submodule PR
+ parent-bump PR per the two-PR rule.
Copy link
Copy Markdown

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

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: feefa6b1bb

ℹ️ 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 +146 to +151
if (toolName === 'join_organization') {
if (!authCtx.userId) {
throw new Error('Authentication required to join an organization. Sign in with OAuth first.');
}
return trackMCPToolCall(toolName, args, () =>
joinOrganization(args as any, env, {
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 MCP scopes for join_organization calls

executeTool special-cases join_organization before checkToolAccess, so this mutating action skips hasRequiredMcpScope entirely. That means an OAuth token with only non-MCP scopes (for example profile:read) can still change membership on a public org, even though other tools correctly reject that token for missing MCP access. Please run the normal scope gate (or an explicit minimum scope check) before allowing the join handler.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

@buremba buremba Apr 21, 2026

Choose a reason for hiding this comment

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

Fixed in 08bc5fcjoin_organization now runs hasRequiredMcpScope("read", …) before the join handler, so OAuth tokens without any mcp:* scope are rejected. Membership/role bypass (the whole point of the tool) is preserved. New integration test covers a profile:read-only token hitting the gate.

Addresses Codex P2 review on #296: join_organization bypassed
checkToolAccess entirely, so an OAuth token with only non-MCP scopes
(e.g. profile:read) could still change membership on a public org.

Now require at least mcp:read before running the join handler. The
membership/role bypass (the whole point of the tool) is preserved.
@buremba buremba enabled auto-merge (squash) April 21, 2026 21:39
@buremba buremba merged commit 38cf00f into main Apr 21, 2026
13 of 14 checks passed
@buremba buremba deleted the feat/public-org-join branch April 21, 2026 21:54
buremba added a commit that referenced this pull request Apr 22, 2026
…#309)

* feat(owletto-backend): restrict $member list to members, emails to admins

The previous commit on #296 made public workspaces self-serve joinable but
left PII exposed: `manage_entity action=list entity_type=$member` is a
public-readable MCP action, so anonymous or non-member callers hitting
POST /api/{slug}/manage_entity got back every member's email in the
metadata blob. resolve_path on /{owner}/$member/{slug} leaked the same
way.

Policy now enforced in `handleList`, `handleGet`, and `_resolvePath`:

- Non-members of the org cannot see the member list or any individual
  $member entity — both paths throw a "join the workspace" error rather
  than returning a sanitized row, so the very existence of member entries
  isn't catalog-scraped by anonymous callers.
- Members who aren't admin/owner see the full list + entity but get the
  email field stripped from `metadata`. Non-PII fields (status, role,
  display_name, etc.) remain so the list page still has something to
  render.
- Only admin/owner callers see emails.

Threaded `memberRole` through `ToolContext` so handlers can apply the
policy without extra DB lookups; the REST proxy + MCP session path both
populate it from the existing `AuthContext.memberRole`.

Pairs with owletto-web PR #15, which adds the Join bar and gates the
Members/Agents pages client-side so the UI never even issues these calls
as a non-member.

* chore: bump owletto-web to include public-org Join bar (#15)

Pulls in the client-side half of the public-workspace privacy changes:
sticky Join bar for non-members, Members page gated to members, and
Agents page read-only for non-members — pairs with the backend policy
tightening in the previous commit on this branch.

* fix(owletto-backend): auto-provision $member entity_type on get, add page-auth coverage

etHandleGet now calls ensureMemberEntityType when slug=$member has no row,
unstucking the /devops/%24member page. Adds page-auth-coverage integration
tests covering anon / non-member / member / owner states across the APIs
the workspace pages depend on, with an explicit regression for the
$member auto-provision path.
buremba pushed a commit that referenced this pull request Apr 23, 2026
Follow-ups from an aggregated teammate review of 128 PRs merged between
2026-04-09 and 2026-04-23. Five concrete gaps patched:

- worker: constrain UploadUserFile to the workspace root (#203 follow-up).
  path.join allowed `../` and absolute paths to escape the workspace. Now
  resolves and rejects anything outside workspaceDir when one is set.
- core: flip Sentry sendDefaultPii to false (#172 follow-up). User content
  and identifiers flow through this stack; the schema has no scrubbing so
  PII-by-default was unsafe.
- gateway: make SlackInstructionProvider extend BaseInstructionProvider
  (#269 follow-up). Sibling Skills/Network providers are wrapped in a
  try/catch that returns "" on error; Slack was bypassing it and would
  crash session-context assembly if listConnections threw.
- owletto-backend: rate-limit the join_organization MCP tool to match the
  REST endpoint (#296 follow-up). Keyed on userId since MCP calls don't
  carry a client IP.

Skipped one reviewer finding: removing the process.env fallback for API
keys at worker.ts:1099/1109 (the inconsistency with #225 base-URL code).
Embedded/dev workers depend on that fallback since credentialStore is
only populated from gateway-supplied session context.
buremba added a commit that referenced this pull request Apr 23, 2026
…instr guard, MCP join rate-limit) (#325)

* fix: address gaps found in post-merge review of last 2 weeks of PRs

Follow-ups from an aggregated teammate review of 128 PRs merged between
2026-04-09 and 2026-04-23. Five concrete gaps patched:

- worker: constrain UploadUserFile to the workspace root (#203 follow-up).
  path.join allowed `../` and absolute paths to escape the workspace. Now
  resolves and rejects anything outside workspaceDir when one is set.
- core: flip Sentry sendDefaultPii to false (#172 follow-up). User content
  and identifiers flow through this stack; the schema has no scrubbing so
  PII-by-default was unsafe.
- gateway: make SlackInstructionProvider extend BaseInstructionProvider
  (#269 follow-up). Sibling Skills/Network providers are wrapped in a
  try/catch that returns "" on error; Slack was bypassing it and would
  crash session-context assembly if listConnections threw.
- owletto-backend: rate-limit the join_organization MCP tool to match the
  REST endpoint (#296 follow-up). Keyed on userId since MCP calls don't
  carry a client IP.

Skipped one reviewer finding: removing the process.env fallback for API
keys at worker.ts:1099/1109 (the inconsistency with #225 base-URL code).
Embedded/dev workers depend on that fallback since credentialStore is
only populated from gateway-supplied session context.

* fix: address remaining gaps from post-merge review

Second pass on the 2-week PR review. Five more gaps closed:

- gateway: unit tests for verifyOwnedAgentAccess covering owner, cross-tenant,
  cross-platform, agent-scoped, admin-bypass, unknown-agent, and external
  OAuth mismatches (#285 follow-up). Closes the test hole in the cross-tenant
  ownership guard.
- owletto-backend: validate each CSP frame-ancestor entry against a strict
  host-source / scheme-source grammar before joining (#246 follow-up).
  Malformed env entries like `https:// lobu.ai` are now dropped instead of
  silently rendered into the directive.
- owletto-backend: introduce normalizeHost() in utils/public-origin and use
  it from getSubdomainZone, extractSubdomainOrg, getCanonicalRedirectUrl, and
  the BetterAuth trustedOrigins wiring (#234/#224/#214 follow-up). Unifies
  the ad-hoc .toLowerCase()/.replace() patterns and adds IDN→punycode so
  `müller.lobu.ai` matches the ASCII zone configured in env.
- owletto-backend: redact member emails that surface via template_data and
  tab template_data in resolve_path, not only on the single resolved entity
  (#309 follow-up). A dashboard data source that enumerates $member entities
  no longer leaks emails to non-admin callers. New utils/member-redaction
  helper plus unit coverage.
- owletto-backend: treat #311 as already closed — ToolContext.memberRole is
  `string | null` (required, not optional), so TypeScript already catches
  future literal omissions at construction.

---------

Co-authored-by: Claude <noreply@anthropic.com>
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