feat(owletto-backend): public-org read access + self-serve join#296
Conversation
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.
There was a problem hiding this comment.
💡 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".
| 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, { |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
Fixed in 08bc5fc — join_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.
…#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.
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.
…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>
Summary
GET /api/:orgSlug/public/{organization,agents,events}. SSE filters invalidation events to a public-safe key allowlist.POST /api/:orgSlug/joinfor self-serve membership. Session-gated, rate-limited (10/hr/IP), idempotent. Mirrors Better Auth'safterAddMemberside effects (ensureMemberEntity+ cache invalidation).join_organizationMCP 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 inexecuteTool.checkToolAccessto surfacejoin_organizationand 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=joinflow, dashboard/sidebar/notifications gating) ship as a separate submodule PR inlobu-ai/owletto-web+ a parent bump PR here, per the two-PR submodule rule.Test plan
bun run typecheckpasses across the repomake build-packagespassespackages/owletto-backend/src/__tests__/integration/mcp/public-org-join.test.tscovers: anonymous public REST reads, join happy path + idempotency, 401/403/404 variants, MCP write-denial message mentionsjoin_organization, MCPjoin_organizationflipsmanage_entity:createfrom denied to allowed, read-only scope note