Customer stories

Customer story · Entitlements, Trust, errors

When the bouncer is load-bearing

Digital Hank is a business-aware executive assistant — it files the invoice, moves the meeting, drafts the reply, and keeps the business state underneath it while you approve. It is a web app, a macOS app and an iOS app, and one subscription entitles you on all of them. If ProLend is Crossdeck watching, this is Crossdeck deciding: three things here fail closed if Crossdeck is wrong — whether the product works for you, whether you get through the signup door, and whether anyone finds out it broke.

  • SDKs@cross-deck/node + /web
  • StackNext.js on Cloud Run
  • SurfacesWeb, macOS, iOS
  • IdentityThe connected mailbox

Observing versus deciding

Most analytics installs are observational. If the SDK is down you lose a chart, and nobody's Tuesday is ruined. Hank's is not that. Crossdeck answers a question the product cannot proceed without — may this person do this? — so the install is engineered as infrastructure rather than instrumentation, and it shows in the choices.

The clearest one is where the client is created. It is not lazy.

lib/crossdeck-server.ts
// Node is the bouncer at the door. A bouncer who only turns up when somebody
// knocks is not a bouncer — and everything that will sit behind this one
// (entitlement decisions, Trust, the signup gate) needs it already standing
// there, warm, with its handlers wired, before the first request arrives.

That comment records a real bug being closed: the client had been constructed on first use, behind a flag that was deliberately off, so it never booted, never wired its process handlers and never sent a heartbeat. Booting is not enforcing. The SDK is up from server start whether or not entitlement decides anything that request.

One key, and no local clock

Hank has a single entitlement key. A trial and a paid subscription resolve to the same one; what separates them is provenance carried on the record, not a second key and not a date the app works out for itself.

lib/entitlements.ts
export const ENT = { PRO: "pro" } as const;

const hint = { userId: mailbox };
await cd.getEntitlements(hint);              // warm + refresh
const pro = cd.isEntitled(hint, ENT.PRO);    // sync; honours validUntil
const rec = cd.listEntitlements(hint).find((e) => e.key === ENT.PRO);

isEntitled() is synchronous, so the access check on a hot path never awaits a network call. getEntitlements() is what warms and refreshes it.

The reason the distinction matters is written into the code as a user-facing failure, not a technical one: read it wrong and a customer on day 2 of a trial is told Hank is active, never sees a countdown, and finds out the trial existed on the morning it ends.

There is also a comment about the key itself that is worth quoting exactly, because it is the failure mode nobody catches in review: Crossdeck follows our name character for character, and a mismatched key reads false exactly like a genuine non-grant — a silently locked-out customer.

The cold container problem

Cloud Run is serverless. A container can start with an empty cache, and a Crossdeck blip overlapping that window would read a paying customer as un-entitled — a false no at the worst possible moment.

So the SDK is given a durable store. It is only ever touched from async paths, which is what keeps isEntitled() synchronous while still giving a cold container last-known-good to serve.

lib/crossdeck-server.ts — last-known-good
const entitlementStore: EntitlementStore = {
  async load(key) { /* read the snapshot from Firestore */ },
  async save(key, value) { /* write it back */ },
};

const cd = new CrossdeckServer({
  secretKey,                       // from the environment, never in the file
  appId: "app_node_…",
  errorCapture: { onUncaughtException: false, onUnhandledRejection: false },
  entitlementStore,
});

Sandbox versus production is inferred from the key prefix (cd_sk_test_ / cd_sk_live_) rather than a separate environment flag — so there is no second switch to fall out of step with the credential.

The absence of a token is the signal

Crossdeck Trust runs on the signup door. The panel in the browser mints a single-use attestation proving a real browser was present; a server-to-server bot cannot mint one. The interesting part is what that means logically — the missing token is the evidence, not the present one.

app/api/trust/gate/route.ts
const verdict = await cd.gate({ email, ip, token });

return NextResponse.json({
  action: verdict.action,
  allow: verdict.allow,
  reference: verdict.reference ?? null,
});

Fail-open, never fail-allow. An adblocker, an outage or a timeout also produces no token — and in Hank's words, a real person signing up on a locked-down laptop must not be turned away because our bouncer had a bad day. The verdict is recorded either way; only an explicit block is ever acted on.

Keyed on the thing nobody can re-create

Crossdeck asks for your environment's strongest UID. Most teams reach for the auth uid. Hank does not, and the reasoning is the most transferable thing in this install: a work address of several years is the thing nobody recreates; a Firebase uid is one signup away. So the connected mailbox is the identity, and the login rides along as a trait.

It is stamped once, at first connection, rather than derived each time — because if it were re-derived, connecting a second inbox could silently change which customer the account resolves to, and a paying customer would read as un-entitled while their card is still being charged.

One more detail, and it is the kind that costs a day when it bites: Crossdeck matches crossdeck_ref against the value passed to identify() as an exact string. No format inspection, no shape validation. Which means Wes@Pinet.co.za and wes@pinet.co.za are two different customers. Hank normalises once, on the way in, and every reader takes the stored value verbatim.

One subscription, three apps, no client with an opinion

Hank is a web app, a macOS app and an iOS app. Subscribe on any of them and you are entitled on all of them — and the reason that works is a rule stated flatly in their own build directive:

Crossdeck is the north star and the ledger. The Mac app never decides for itself whether somebody is entitled — it asks. Never a Mac-only opinion about who is entitled.

— HANK-DIRECTIVE-Mac-App.md

This is the part most cross-platform products get wrong. The usual shape is one entitlement per store: an App Store subscription unlocks the phone, a Stripe subscription unlocks the web, and the customer who paid on one is told to pay again on the other. It happens because each client is allowed to work out its own answer from whatever receipt it can see.

Hank has no such client. Every surface asks the same question of the same ledger and gets the same answer, because the payment rail is an input, not the identity. Stripe on the web and StoreKit on iOS are two rails resolving to the one entitlement key.

lib/entitlements.ts
export const ENT = { PRO: "pro" } as const;

// One key, not two. The trial is a Stripe subscription in its trial window, so
// it arrives on the same rail as a paid one and resolves to the same key.
// Same story on iOS: StoreKit is another rail resolving to this key.

The trial follows the same rule — read, never computed. It is granted and expired by Crossdeck, so a countdown shown on the Mac cannot disagree with the one shown on the phone, and no surface can extend its own trial by being wrong about the date.

What makes the answer match

None of this holds unless every surface asks about the same person. That is why the identity is resolved by Hank's own backend and served to each client, rather than taken from whatever the auth provider hands that platform:

app/api/me/route.ts
// Served from here so every surface (web, and iOS later) identifies on the
// SAME string: the channel Hank sits on, resolved by us, never by the auth
// provider. That is what makes it match across platforms.

It matters more than it looks. Sign in with Apple hands iOS a Private Relay address that does not match what the web sees — so a product keyed on the auth provider's email has already split one customer in two before anyone has paid. Hank resolves the mailbox itself and every platform reads the identical string.

And the surfaces that never run a browser

The web identifies through the browser SDK. The Mac and the phone do not run it, so somebody who only ever used the desk was invisible — no customer record, and a payment arriving with their crossdeck_ref landing in the review queue as a rail-only customer waiting for a human.

Shipping an SDK to each app would have fixed it three times and drifted three ways. The server already knows who did what, so it says so once:

lib/crossdeck-server.ts — one path, every surface
export function trackFor(mailbox, name, properties) {
  const cd = crossdeck();
  if (!cd || !mailbox) return;
  try {
    cd.track({ name, developerUserId: mailbox, ...(properties ? { properties } : {}) });
  } catch {
    // Observing a thing must never break the thing.
  }
}

developerUserId is the same mailbox the web passes to identify() and the same string stamped on the subscription. One identity, three places, one ledger — and, in their words, it keeps working the day a fourth surface appears.

Owning the crash handlers on purpose

The Node SDK auto-wires uncaughtException and unhandledRejection on construction. Hank turns both off and wires them itself — leaving the SDK's on alongside its own would capture every error twice. wrapFetch, the automatic capture of failed outbound HTTP, stays on.

The uncaughtException path is the one worth copying. Node's own documentation says process state is undefined after it fires, so Hank captures, gives the queue a short window to drain, then exits non-zero so Cloud Run replaces the container rather than serving from a broken one — with a two-second cap so a wedged transport cannot block shutdown forever.

And when the secret key is missing in production, it is not a silent condition. It logs that the server SDK is not running and that errors, analytics and entitlement resolution are all blind — because a door that is not there looks exactly like a door that is open.

Why this install is worth reading

  • One entitlement, every surface, and no client allowed an opinion. Subscribe on the web, be entitled on the Mac and the phone — because the rail is an input and the ledger is the answer. This is the thing most cross-platform products charge their customers twice for.
  • Crossdeck is in the critical path, and is built like it. Eager boot, a durable store behind a synchronous check, fail-open gating, loud failure when unconfigured.
  • Both SDKs, with a clean division. The browser handles page views and identity; every decision that matters is made server-side, where the customer cannot reach it.
  • The wiring was sequenced, not bundled. Boot, then identify, then gate — three steps with three contracts. The provider component deliberately does not identify anyone, because folding the steps together is how the order gets done wrong.
  • Identity is a product decision before it is a technical one. Choosing the mailbox over the auth uid is what makes trial eligibility and abuse limits survive a re-signup.

One thing this install taught us

The Trust route carries a comment we would rather not have earned:

The Crossdeck write-up says cd.trust.gate(…). The installed SDK puts gate directly on CrossdeckServer. Checked against the shipped types rather than the docs.

— app/api/trust/gate/route.ts

A developer had to disbelieve our documentation and read our type definitions to get the call right. That is our bug, it is logged, and it is on this page because a case study that only shows the parts that went well is an advertisement.

The same install, documented

Access is a question your product already asks

Most apps answer it with a boolean somebody has to keep true. Crossdeck answers it from the rails you already own.