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
documentstable 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 equalsctx.principal.subject.memberIdon 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.
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:
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:
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.
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:
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
documentSharestable 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
- Sign in as member A, create a document, and share it with member B from the
ShareMenu(confirm the request body carries B'smember.id). - Sign in as member B and load
GET /documents— the shared document appears. - Sign in as member C (same org, no share) — the document is absent.
- 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. - Call
POST /documents/{id}/sharewith a service key — the gateway denies it403 member_subject_requiredbefore your handler runs. - 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.userExternalIdinstead ofmember.id. The recipient's request carriesmemberId(the Membership id), which never equals the IdPuserExternalId— so the join finds nothing. Storemember.id. - A recipient in another org can see the document. A read query dropped the
orgIdfilter. Every member-keyed read must includeorgId: 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
ownerIdis stored as the owner'smemberId, not theiruserExternalId. - Service traffic reaches the handler. The route is missing
requireMember: truein 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 sendmember.id(NOTuserExternalId) tofs.route.post('/documents/:id/share', { memberId }). In the backend, wrap handlers withfs.handler(async (ctx, req, res) => …), callrequireMember(ctx), and write a shares row stamped withctx.principal.org.id. MakeGET /documentsreturn owned-or-shared rows always scoped by the principal's org. Validate recipient membership server-side; the browser roster is untrusted. Declare the routes withfs.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 carriesctx.principal. - Permission gates — gate shared, org-wide surfaces by role in your UI.