Storing per-user data
Key backend records from the signed principal and create them without first-request races.
Key backend records from the signed principal and create them without first-request races.
Farther Shore authenticates the subscriber, resolves the organization and subject, enforces the route contract, and sends your backend a signed principal. Your application still owns its domain data: projects, documents, jobs, user preferences, and any provider records that do not have a platform representation.
Strict fs.middleware() verifies the gateway signature, attaches
req.fartherShore, and strips inbound x-fs-* headers. Use fs.handler() when
the handler requires a principal:
import { requireMember, requireService } from "@farthershore/backend";
app.get(
"/v1/profile",
fs.handler(async (ctx, _req, res) => {
const subject = requireMember(ctx);
const profile = await findOrCreateMember({
orgId: ctx.principal.org.id,
memberId: subject.memberId,
});
res.json(profile);
}),
);
The verified principal has one organization and exactly one subject:
type Principal = {
org: { id: string };
subject:
| {
kind: "member";
memberId: string;
via: "session" | "api_key";
keyId?: string;
}
| {
kind: "service";
serviceAccountId: string;
keyId: string;
};
};
memberId is the stable Farther Shore member id, not an email address or
identity-provider subject.serviceAccountId is the stable organization-owned machine identity; keyId
identifies the rotating credential used for audit.org.id is the subscriber organization boundary. Include it in every tenant
query even when the subject id is expected to be globally unique.After a contextual request is verified, the signed lifecycle identifiers are
available on ctx.signedContext:
| Application need | Verified accessor |
|---|---|
| Business | ctx.signedContext.businessId |
| Accepted compiled plan | ctx.signedContext.compiledPlanId |
| Subscription | ctx.signedContext.subscriptionId |
| Subscriber/customer | ctx.signedContext.subscriberId |
| Environment | ctx.signedContext.environmentId |
signedContext is optional on the raw fs.middleware() context because a
valid signed request may intentionally carry no customer context. It is present
inside fs.handler() after contextual verification succeeds. Never substitute
body, query, or ordinary header values for these signed identifiers.
If a route accepts only people or only service accounts, declare that in the
business program with requireMember: true or requireService: true. The
gateway rejects the other subject type before forwarding, and the SDK helper
narrows the TypeScript type inside the handler.
A platform identity may reach your backend before your database has a local
row. Treat the first verified request as an idempotent synchronization point.
Do not implement this as SELECT, then an unguarded INSERT: two concurrent
requests can both observe “missing” and race.
First, enforce the invariant in the database:
model AppMember {
id String @id @default(cuid())
orgId String
memberId String
createdAt DateTime @default(now())
@@unique([orgId, memberId])
}
Then use the database's atomic upsert or insert-on-conflict primitive:
async function findOrCreateMember(identity: {
orgId: string;
memberId: string;
}) {
return db.appMember.upsert({
where: {
orgId_memberId: {
orgId: identity.orgId,
memberId: identity.memberId,
},
},
create: identity,
update: {},
});
}
In SQL, the equivalent shape is INSERT ... ON CONFLICT (org_id, member_id) DO UPDATE/NOTHING RETURNING .... The unique constraint is essential; application
locking alone does not protect multiple replicas.
Use the same pattern for organization records:
model AppOrganization {
id String @id @default(cuid())
orgId String @unique
}
const appOrg = await db.appOrganization.upsert({
where: { orgId: ctx.principal.org.id },
create: { orgId: ctx.principal.org.id },
update: {},
});
This lets an application work on the first request without waiting for a webhook. Webhooks remain useful for asynchronous projections, cleanup, and prewarming, but they should not be the only path that makes a verified request usable.
A route permission proves the subject may invoke an operation; it does not prove that an arbitrary record id belongs to the same organization. Scope every lookup to the signed organization:
const project = await db.project.findFirst({
where: {
id: req.params.projectId,
orgId: ctx.principal.org.id,
},
});
if (!project) return res.status(404).json({ error: "not_found" });
Use requirePermission(ctx, "projects:write") only for checks finer than the
compiled route policy. It reads the verified permission claim and fails closed
when the claim is absent. Hiding a frontend control is not a replacement for
either gateway enforcement or tenant-scoped database queries.
x-fs-* headers; the middleware removes them intentionally.ctx.signedContext rather than
permanently copying it into an authorization column.