Farther ShoreDocs
Go to Farther Shore
Frontend SDK
Root & data components
Auth & sessions
Access-aware UI
Permission gates
Custom components
Variables
@farthershore/farthershore-js
Share with another member
OutcomePrerequisitesPick the recipient — member.id, not userExternalIdDeclare the routes as member-onlyStore the shareEnforce on readWhy no platform ReBACVerify it worksCommon failuresRecoverAgent promptRelated
Shared and private in one portal
@farthershore/farthershore-js exports
@farthershore/farthershore-js/react exports
@farthershore/farthershore-js/test-utils exports
@farthershore/farthershore-js/errors exports
@farthershore/farthershore-js/components exports
@farthershore/farthershore-js/components/docs-chrome exports
frontend-sdk HTTP contracts
Status
Docs/Cookbook/Share with another member

Share with another member

Let a member share a document with a teammate — Google-Drive-style — built in your own backend on the platform's verified, correlatable member id.

You want the everyday collaboration move: a member opens a document they own and shares it with a specific teammate, who then sees it in their own list. This is not a platform feature you turn on — it's ordinary application data you store, exactly like every real app does. The platform's job is narrower and load-bearing: it hands your backend a verified member id on every request, and that id is the same id you listed in the share menu. That one correlation is all you need to build sharing safely.

The three tenancy boundaries stay exactly as they are — the org is the billing boundary, the member is the visibility boundary, and roles are the capability boundary. Sharing lives on the member axis: it decides who can see a document, and it needs no Team RBAC at all (more on that below).

Outcome

A member can share a document they own with another member of the same org, and the recipient sees it in their document list — enforced entirely by your backend, keyed on the platform's verified memberId, and never leaking across orgs.

Prerequisites

  • A Farther Shore business with a backend you run
  • A backend that verifies platform requests (see metering & verification)
  • A documents table you own, and somewhere to store shares
  • A portal or custom frontend built with @farthershore/farthershore-js

Pick the recipient — member.id, not userExternalId

The share menu lists the org's members with useTeam(). Each TeamMember carries two identifiers, and picking the wrong one is the single most common way to break sharing:

  • member.id — the internal Membership id. This is what you store and send, because it equals ctx.principal.subject.memberId on the recipient's next request. It is the only field that correlates.
  • member.userExternalId — the identity-provider id (Clerk/SAML subject). Never store this as a share key: it does not match anything your backend sees on a request, so the recipient's reads would silently return nothing.

Store member.id, never member.userExternalId. member.id is the Membership id and is exactly the memberId your backend reads from ctx.principal.subject.memberId. userExternalId is the IdP's id — it never appears on a verified request, so a share keyed on it matches no one. This is the load-bearing rule of the whole recipe.

tsx
import {
  useTeam,
  useFartherShore,
  type TeamMember,
} from "@farthershore/farthershore-js/react";

function ShareMenu({ docId }: { docId: string }) {
  const fs = useFartherShore();
  const team = useTeam();
  const members: TeamMember[] = team.data?.members ?? [];

  async function shareWith(memberId: string) {
    // Send the Membership id. This is the value the recipient's request will
    // carry as ctx.principal.subject.memberId — the correlation that makes the
    // share resolve on read.
    await fs.route.post(`/documents/${docId}/share`, { memberId });
  }

  // Roster rows carry a display name and email when the identity provider
  // supplies them; fall back to a short id rather than showing a raw UUID.
  // (This mirrors what the SDK's own components render for members.)
  function label(
    m: TeamMember & { name?: string | null; email?: string | null },
  ) {
    return m.name ?? m.email ?? `Member ${m.id.slice(0, 8)}`;
  }

  return (
    <ul>
      {members.map((m) => (
        <li key={m.id}>
          <span>{label(m)}</span> {/* display only — never the share key */}
          <button onClick={() => void shareWith(m.id)}>Share</button>
          {/*                                   ^^^^ m.id, NOT userExternalId */}
        </li>
      ))}
    </ul>
  );
}

Display identity comes from the roster row itself — name/email when the provider supplies them. useFsAuth() / useSession() describe only the caller, so they cannot label teammates. The share key is always member.id.

Declare the routes as member-only

Sharing is a human action and private data is per-member, so both the write and the reads must be member subjects. Declare that in your business program and the gateway rejects machine (service-key) traffic at the edge with 403 member_subject_required — before it ever reaches your handler:

ts
import * as fs from "@farthershore/business";

const requests = fs.requests();
const api = fs.backend("api", {
  transport: { mode: "direct" },
  meters: [requests],
  default: true,
});
const documents = fs.route("/documents", {
  get: { backend: api, requireMember: true, costs: [requests.fixed(1)] },
  post: { backend: api, requireMember: true, costs: [requests.fixed(1)] },
});
const documentShare = fs.route("/documents/{id}/share", {
  post: { backend: api, requireMember: true, costs: [requests.fixed(1)] },
});

// Grant both refs in the plan that sells this feature:
//   fs.plan("team", { kind: fs.plan.kind.flat, …, grants: [documents, documentShare] });

Create or bind the same api logical slug in every environment you test. A preview never borrows the production origin. Wait until env list contains the pushed branch's preview before creating its backend row; if automatic branch-prefix creation did not occur, create the preview explicitly first:

bash
farthershore env list <business> --format json
farthershore backend create <business> --env <environment> \
  --name "Preview API" --slug api --transport direct \
  --idempotency-key <persisted-backend-create-attempt-key> \
  --origin-url https://preview-api.example.com --default --format json
farthershore backend list <business> --format json

Filter the structured backend list by the preview's environment id.

Store the share

The frontend fs.route.post(...) above forwards through the gateway to your backend, which writes an ordinary shares row. Two rules make it safe: the sharer must own the document, and you stamp the org id onto the row so reads can never cross tenants.

ts
import { fartherShore, requireMember } from "@farthershore/backend";

const fs = fartherShore.initFromEnv();

// POST /documents/:id/share   body: { memberId }
// Register fs.middleware() first; fs.handler requires its verified context.
app.post(
  "/documents/:id/share",
  fs.handler(async (ctx, req, res) => {
    const me = requireMember(ctx); // the sharer, narrowed to a member subject
    const orgId = ctx.principal.org.id;
    const documentId = req.params.id;
    const memberId: unknown = req.body?.memberId;
    if (typeof memberId !== "string" || memberId.length === 0) {
      return res.status(400).json({ error: "invalid_member_id" });
    }

    // Application-owned trusted directory; never trust the browser roster.
    if (!(await membershipDirectory.isActiveMember({ orgId, memberId }))) {
      return res.status(404).json({ error: "not_found" });
    }

    // The sharer must own the document, within their own org.
    const doc = await db.documents.findFirst({
      where: { id: documentId, orgId, ownerId: me.memberId },
    });
    if (!doc) return res.status(404).json({ error: "not_found" });

    // Stamp orgId on the share row — the read query always filters by it.
    await db.documentShares.upsert({
      where: { documentId_memberId: { documentId, memberId } },
      create: { documentId, memberId, orgId, grantedBy: me.memberId },
      update: {},
    });
    res.json({ ok: true });
  }),
);

The browser can submit any memberId, regardless of what useTeam() displayed. membershipDirectory.isActiveMember above is an application-owned adapter, not an SDK export: implement it against a trusted server-side membership source and fail closed when membership cannot be established. Keep removals synchronized, enforce ownership and membership within your database transaction where possible, and retain the organization filter on every read. A roster dropdown is never membership validation.

Enforce on read

The recipient's request carries their own verified memberId. The list query returns documents they own or that are shared with them — and is always scoped by ctx.principal.org.id, so a share can never resolve across orgs:

ts
import { requireMember } from "@farthershore/backend";

// GET /documents  → mine OR shared-with-me, never cross-org
app.get(
  "/documents",
  fs.handler(async (ctx, req, res) => {
    const me = requireMember(ctx);
    const orgId = ctx.principal.org.id;

    const docs = await db.documents.findMany({
      where: {
        orgId, // ALWAYS scope by the principal's org — the tenancy floor
        OR: [
          { ownerId: me.memberId }, // documents I own
          { shares: { some: { memberId: me.memberId, orgId } } }, // shared with me
        ],
      },
    });
    res.json(docs);
  }),
);

That is the whole loop: the recipient's me.memberId came from the same signed principal the platform verified, and it matches the memberId the sharer picked from useTeam() — so the shared row resolves, and only for the right person in the right org.

Why no platform ReBAC

The platform does not model your sharing graph, and it doesn't need to. The who-shared-what-with-whom relationships are ordinary rows in your database — the same way Google Drive stores its ACLs. What you cannot build yourself is a trustworthy answer to "who is calling?"; that is exactly what the platform supplies, unforgeably, as ctx.principal.subject.memberId, correlatable to the member.id you rendered in the UI.

So there is nothing to turn on:

  • No relationship engine (ReBAC/Zanzibar). Your documentShares table is the relationship store. The platform's contribution is the verified, correlatable id — not the graph.
  • No Team RBAC. RBAC is the capability axis and is strictly opt-in (a dashboard toggle, or farthershore business rbac enable). Sharing is the visibility axis: a member with only a member role can still be granted a share, because "may open this document" is not a role. The two are orthogonal — leave RBAC disabled in the dashboard and sharing still works.

Reach for RBAC only when you also need org-wide capability rules (who may export, who may delete) layered on top — see Shared and private in one portal, which combines an RBAC-gated shared section with member-keyed private data in one frontend.

Verify it works

  1. Sign in as member A, create a document, and share it with member B from the ShareMenu (confirm the request body carries B's member.id).
  2. Sign in as member B and load GET /documents — the shared document appears.
  3. Sign in as member C (same org, no share) — the document is absent.
  4. Sign in as a member of a different org — the document is absent even if you replay B's id, because the query is scoped by ctx.principal.org.id.
  5. Call POST /documents/{id}/share with a service key — the gateway denies it 403 member_subject_required before your handler runs.
  6. Submit another organization's member id directly, bypassing the dropdown; require rejection and no share row. Repeat for a removed member and for a caller who does not own the document.

Common failures

  • Nothing shows up for the recipient. You stored member.userExternalId instead of member.id. The recipient's request carries memberId (the Membership id), which never equals the IdP userExternalId — so the join finds nothing. Store member.id.
  • A recipient in another org can see the document. A read query dropped the orgId filter. Every member-keyed read must include orgId: ctx.principal.org.id; the shares join must carry it too.
  • The share write 404s for a legitimate owner. The ownership check keys on the wrong id — confirm ownerId is stored as the owner's memberId, not their userExternalId.
  • Service traffic reaches the handler. The route is missing requireMember: true in the manifest; add it and rebuild.

Recover

Sharing lives entirely in your data model, so recovery is a data operation, not a platform one: delete the offending documentShares rows (or add the missing orgId filter and redeploy your backend). No contract change or republish is involved — the manifest only declares the routes as member-only.

Agent prompt

In this Farther Shore repo, add member-to-member document sharing. In the frontend, list recipients with useTeam() and send member.id (NOT userExternalId) to fs.route.post('/documents/:id/share', { memberId }). In the backend, wrap handlers with fs.handler(async (ctx, req, res) => …), call requireMember(ctx), and write a shares row stamped with ctx.principal.org.id. Make GET /documents return owned-or-shared rows always scoped by the principal's org. Validate recipient membership server-side; the browser roster is untrusted. Declare the routes with fs.route(path, { post: { requireMember: true } }) and grant their refs in the plan. Leave RBAC disabled in the dashboard — sharing is the visibility axis, not a capability. Build and report the preview test steps; do not publish.

Related

  • Tenancy & identity — the org/member/role boundaries and the verified principal this recipe stands on.
  • Team RBAC — the opt-in capability axis, orthogonal to sharing.
  • Metering & verification — how fs.middleware() verifies the request that carries ctx.principal.
  • Permission gates — gate shared, org-wide surfaces by role in your UI.
Previous@farthershore/farthershore-jsNextShared and private in one portal

On this page

OutcomePrerequisitesPick the recipient — member.id, not userExternalIdDeclare the routes as member-onlyStore the shareEnforce on readWhy no platform ReBACVerify it worksCommon failuresRecoverAgent promptRelated