Policy gate recipes

Embed
Preview of the card
<a href="https://forumulate.com/guides/policy-gate-recipes"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/2d0b12dc-5f21-451a-8885-1626134903f9/social-card.svg?v=3e7ae33eec8b" alt="Policy gate recipes" width="600" height="315"></a>

Complete code for putting the policy acceptance gate in front of sign-up and sign-in, for the platform that signs your users in. Every recipe uses the same three pieces:

  • status: what this user still has to accept before this event;

  • a way to show it: the widget, <PolicyGate>, or the hosted page;

  • a token your server verifies before it lets the event finish.

Before you start

  1. Publish your policies in a Legal space.

  2. In the space's Policy gate settings, rotate a key. Keep the id and the secret, which is shown once.

  3. Create a personal access token with the policy:read and policy:accept scopes.

  4. If you will use the hosted page, add your return addresses to page.returnUrls.

Every recipe reads these environment variables:

FORUMULATE_TOKEN=fpat_…                         # the PAT
FORUMULATE_ORG=…                                # organization id
FORUMULATE_LEGAL_SPACE=…                        # the Legal space id
FORUMULATE_POLICY_GATE_KEYS=pgk_…:pgs_…         # add the old key here while a rotation overlaps

Install the SDK on your server: npm install @forumulate/sdk. For React, also npm install @forumulate/react.

The code on this page is type-checked against the SDK in our test suite. A block that starts with // not type-checked: says why it is not.

Auth0

A post-login Action asks for the user's status. If something blocking is pending, it sends the user to the hosted page and finishes the login when they come back with a token.

Auth0 resumes a login at /continue only with the state it appends to the redirect, and the Action cannot see that value in advance. So the Action redirects to a small bridge endpoint of yours. The bridge reads Auth0's state, signs it into the ticket, and sends the user on to the hosted page, which returns to /continue with that state.

1. The Action (Actions → Library → Build custom → Login / Post Login). Add @forumulate/sdk under Dependencies, and the variables above as Secrets, plus BRIDGE_URL (for example https://app.example.com/auth0/policies).

const {
  ForumulateClient,
  signPolicyTicket,
  verifyAcceptanceToken,
} = require('@forumulate/sdk');

// Runs after every successful login.
exports.onExecutePostLogin = async (event, api) => {
  const forumulate = new ForumulateClient({
    baseUrl: 'https://forumulate.com/api',
    token: event.secrets.FORUMULATE_TOKEN,
  });
  const space = { orgId: event.secrets.FORUMULATE_ORG, spaceId: event.secrets.FORUMULATE_LEGAL_SPACE };
  const isSignUp = event.stats.logins_count <= 1;
  const status = await forumulate.policies.status({
    ...space,
    subject: event.user.user_id,
    event: isSignUp ? 'sign_up' : 'sign_in',
  });
  if (status.satisfied) return;

  // A ticket for this user, without a return address yet: the bridge adds it.
  const ticket = await signPolicyTicket(
    {
      ...space,
      subject: event.user.user_id,
      event: isSignUp ? 'sign_up' : 'sign_in',
      email: event.user.email,
      sourceApp: 'auth0',
    },
    event.secrets.FORUMULATE_POLICY_GATE_KEYS,
  );
  api.redirect.sendUserTo(event.secrets.BRIDGE_URL, { query: { ticket } });
};

// Runs when the user comes back to /continue from the hosted page.
exports.onContinuePostLogin = async (event, api) => {
  const token = event.request.query.policy_token;
  if (event.request.query.policy_error === 'declined' || !token) {
    return api.access.deny('You need to accept our policies to continue.');
  }
  const check = await verifyAcceptanceToken(String(token), {
    keys: event.secrets.FORUMULATE_POLICY_GATE_KEYS,
    subject: event.user.user_id,
    spaceId: event.secrets.FORUMULATE_LEGAL_SPACE,
    requireSatisfied: true,
  });
  if (!check.valid) return api.access.deny(`Policies not accepted (${check.reason}).`);
};

2. The bridge, on your own server. Add https://YOUR_TENANT.auth0.com/continue (or your custom domain's) to the space's page.returnUrls.

import express from 'express';
import { policyGatePageUrl, signPolicyTicket, verifyPolicyTicket } from '@forumulate/sdk';

const app = express();
const keys = process.env.FORUMULATE_POLICY_GATE_KEYS!;
const orgId = process.env.FORUMULATE_ORG!;
const spaceId = process.env.FORUMULATE_LEGAL_SPACE!;
const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN!; // e.g. acme.eu.auth0.com

app.get('/auth0/policies', async (req, res) => {
  const state = String(req.query.state ?? '');
  // The Action's ticket proves which user this is; only our server can sign one.
  const incoming = await verifyPolicyTicket(String(req.query.ticket ?? ''), { keys, spaceId });
  if (!incoming.valid || !state) return res.status(400).send('Invalid policy request.');

  const { sub, evt, eh, app: sourceApp } = incoming.claims;
  const ticket = await signPolicyTicket(
    {
      orgId,
      spaceId,
      subject: sub,
      event: evt,
      emailHash: eh,
      sourceApp,
      returnUrl: `https://${AUTH0_DOMAIN}/continue`,
      state, // the hosted page hands it back, and Auth0 resumes the login with it
    },
    keys,
  );
  res.redirect(policyGatePageUrl({ orgId, spaceId, ticket }));
});

app.listen(3000);

Clerk

Middleware keeps signed-in users who have not accepted the current policies on a /policies page, and a Clerk webhook records the acceptance a new user gave on the sign-up form.

1. Expose the flag in the session token. In Clerk, under Sessions → Customize session token, add:

{ "metadata": "{{user.public_metadata}}" }

2. Middleware (middleware.ts). It re-checks a user every 24 hours, so a new version that takes effect reaches everyone within a day.

import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';

const isOpen = createRouteMatcher(['/policies(.*)', '/api/policies(.*)', '/api/webhooks(.*)', '/sign-in(.*)', '/sign-up(.*)']);
const DAY_MS = 24 * 60 * 60 * 1000;

export default clerkMiddleware(async (auth, req) => {
  const { userId, sessionClaims } = await auth();
  if (!userId || isOpen(req)) return;
  const metadata = (sessionClaims?.metadata ?? {}) as { policiesCheckedAt?: number };
  if (metadata.policiesCheckedAt && Date.now() - metadata.policiesCheckedAt < DAY_MS) return;
  const url = new URL('/policies', req.url);
  url.searchParams.set('next', req.nextUrl.pathname);
  return NextResponse.redirect(url);
});

export const config = {
  matcher: ['/((?!_next|.*\\..*).*)', '/(api|trpc)(.*)'],
};

3. The ticket and the record (app/api/policies/ticket/route.ts and app/api/policies/accepted/route.ts).

// app/api/policies/ticket/route.ts
import { auth } from '@clerk/nextjs/server';
import { signPolicyTicket } from '@forumulate/sdk';

export async function GET() {
  const { userId } = await auth();
  if (!userId) return new Response('Sign in first.', { status: 401 });
  const ticket = await signPolicyTicket(
    {
      orgId: process.env.FORUMULATE_ORG!,
      spaceId: process.env.FORUMULATE_LEGAL_SPACE!,
      subject: userId,
      event: 'sign_in',
      sourceApp: 'web',
    },
    process.env.FORUMULATE_POLICY_GATE_KEYS!,
  );
  return Response.json({ ticket });
}
// app/api/policies/accepted/route.ts
import { auth, clerkClient } from '@clerk/nextjs/server';
import { ForumulateClient, verifyAcceptanceToken } from '@forumulate/sdk';

const forumulate = ForumulateClient.fromEnv();

export async function POST(req: Request) {
  const { userId } = await auth();
  if (!userId) return new Response('Sign in first.', { status: 401 });
  const { token } = (await req.json()) as { token: string | null };

  let ok: boolean;
  if (token) {
    const check = await verifyAcceptanceToken(token, {
      keys: process.env.FORUMULATE_POLICY_GATE_KEYS,
      subject: userId,
      requireSatisfied: true,
    });
    ok = check.valid;
  } else {
    // `tokenType: 'none'`: the widget found nothing to accept. Confirm it.
    const status = await forumulate.policies.status({
      orgId: process.env.FORUMULATE_ORG!,
      spaceId: process.env.FORUMULATE_LEGAL_SPACE!,
      subject: userId,
      event: 'sign_in',
    });
    ok = status.satisfied;
  }
  if (!ok) return new Response('Policies not accepted.', { status: 403 });

  const clerk = await clerkClient();
  await clerk.users.updateUserMetadata(userId, { publicMetadata: { policiesCheckedAt: Date.now() } });
  return Response.json({ ok: true });
}

4. The page (app/policies/page.tsx). require() resolves at once, with tokenType: 'none', when there is nothing to accept.

'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useUser } from '@clerk/nextjs';
import { usePolicyGate } from '@forumulate/react';

export default function PoliciesPage() {
  const gate = usePolicyGate({
    org: process.env.NEXT_PUBLIC_FORUMULATE_ORG,
    space: process.env.NEXT_PUBLIC_FORUMULATE_LEGAL_SPACE,
    presentation: 'modal',
  });
  const { user } = useUser();
  const router = useRouter();
  const next = useSearchParams().get('next') ?? '/';
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!gate.ready || !user) return;
    (async () => {
      const { ticket } = await (await fetch('/api/policies/ticket')).json();
      const result = await gate.require({ event: 'sign_in', ticket });
      const saved = await fetch('/api/policies/accepted', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ token: result.tokenType === 'none' ? null : result.token }),
      });
      if (!saved.ok) throw new Error('Could not record your acceptance.');
      await user.reload(); // a fresh session token carries the new flag
      router.replace(next);
    })().catch(() => setError('You need to accept our policies to continue.'));
  }, [gate.ready, user]);

  return <main>{error ? <p role="alert">{error}</p> : <p>Loading our policies…</p>}</main>;
}

5. Sign-up, and the webhook. Show the gate above Clerk's sign-up form and pass the receipt in unsafeMetadata. When Clerk creates the user, its user.created webhook claims the receipt for the new user id.

'use client';
import { useState } from 'react';
import { SignUp } from '@clerk/nextjs';
import { PolicyGate } from '@forumulate/react';

export default function SignUpPage() {
  const [receipt, setReceipt] = useState<string | null>(null);
  if (!receipt) {
    return (
      <PolicyGate
        org={process.env.NEXT_PUBLIC_FORUMULATE_ORG}
        space={process.env.NEXT_PUBLIC_FORUMULATE_LEGAL_SPACE}
        event="sign_up"
        presentation="checkbox-modal"
        autoSubmit={false}
        onAccepted={(r) => setReceipt(r.token)}
      />
    );
  }
  return <SignUp unsafeMetadata={{ policyReceipt: receipt }} />;
}
// app/api/webhooks/clerk/route.ts: subscribe it to user.created
import { verifyWebhook } from '@clerk/nextjs/webhooks';
import { clerkClient } from '@clerk/nextjs/server';
import { ForumulateClient, verifyAcceptanceToken } from '@forumulate/sdk';

const forumulate = ForumulateClient.fromEnv();

export async function POST(req: Request) {
  const evt = await verifyWebhook(req); // checks CLERK_WEBHOOK_SIGNING_SECRET
  if (evt.type !== 'user.created') return new Response('ignored');

  const userId: string = evt.data.id;
  const receipt = evt.data.unsafe_metadata?.policyReceipt as string | undefined;
  const email: string | undefined = evt.data.email_addresses?.[0]?.email_address;
  if (!receipt) return new Response('no receipt'); // the middleware will ask at first sign-in

  const check = await verifyAcceptanceToken(receipt, {
    keys: process.env.FORUMULATE_POLICY_GATE_KEYS,
    use: 'receipt',
    spaceId: process.env.FORUMULATE_LEGAL_SPACE,
    requireSatisfied: true,
  });
  if (!check.valid) return new Response('receipt refused');

  await forumulate.policies.claim({
    orgId: process.env.FORUMULATE_ORG!,
    spaceId: process.env.FORUMULATE_LEGAL_SPACE!,
    receipt,
    subject: userId,
    email,
  });
  const clerk = await clerkClient();
  await clerk.users.updateUserMetadata(userId, { publicMetadata: { policiesCheckedAt: Date.now() } });
  return new Response('ok');
}

Supabase

A custom access token hook puts policies_ok in every access token Supabase issues, and an Edge Function refuses to serve a user whose token says false. Use the HTTP form of the hook (Authentication → Hooks → Customize Access Token → HTTPS), pointed at the first function below.

// supabase/functions/policy-claims/index.ts: the custom access token hook
import { Webhook } from 'npm:standardwebhooks@1.0.0';
import { ForumulateClient } from 'npm:@forumulate/sdk@0.9.0';

const forumulate = new ForumulateClient({
  baseUrl: 'https://forumulate.com/api',
  token: Deno.env.get('FORUMULATE_TOKEN')!,
});
const space = { orgId: Deno.env.get('FORUMULATE_ORG')!, spaceId: Deno.env.get('FORUMULATE_LEGAL_SPACE')! };
// The hook secret Supabase shows you, "v1,whsec_…"; the library wants the base64 part.
const hook = new Webhook(Deno.env.get('ACCESS_TOKEN_HOOK_SECRET')!.replace('v1,whsec_', ''));

Deno.serve(async (req) => {
  const payload = await req.text();
  const { user_id, claims } = hook.verify(payload, Object.fromEntries(req.headers)) as {
    user_id: string;
    claims: Record<string, unknown>;
  };
  try {
    const status = await forumulate.policies.status({ ...space, subject: user_id, event: 'sign_in' });
    return Response.json({ claims: { ...claims, policies_ok: status.satisfied, policies_pending: status.pending } });
  } catch {
    // Forumulate unreachable: issue the token, flagged as unchecked. The
    // functions below treat "unchecked" as "not accepted"; choose your stance.
    return Response.json({ claims: { ...claims, policies_ok: false, policies_pending: ['unchecked'] } });
  }
});

The check, in any Edge Function that serves user data:

// supabase/functions/projects/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2';

Deno.serve(async (req) => {
  const jwt = (req.headers.get('Authorization') ?? '').replace(/^Bearer /, '');
  const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, {
    global: { headers: { Authorization: `Bearer ${jwt}` } },
  });
  const { data, error } = await supabase.auth.getClaims(jwt);
  if (error || !data) return new Response('Unauthorized', { status: 401 });
  if (data.claims.policies_ok !== true) {
    return Response.json(
      { error: 'policies_required', pending: data.claims.policies_pending ?? [] },
      { status: 403 },
    );
  }
  const { data: projects } = await supabase.from('projects').select('*');
  return Response.json(projects);
});

Recording the acceptance. The app shows the gate with a ticket from this function, sends back the token (or, right after sign-up, the receipt), then calls supabase.auth.refreshSession() so the hook issues a token with policies_ok: true.

// supabase/functions/policies/index.ts: GET → a ticket, POST → record
import { createClient } from 'npm:@supabase/supabase-js@2';
import { ForumulateClient, signPolicyTicket, verifyAcceptanceToken } from 'npm:@forumulate/sdk@0.9.0';

const forumulate = new ForumulateClient({ baseUrl: 'https://forumulate.com/api', token: Deno.env.get('FORUMULATE_TOKEN')! });
const orgId = Deno.env.get('FORUMULATE_ORG')!;
const spaceId = Deno.env.get('FORUMULATE_LEGAL_SPACE')!;
const keys = Deno.env.get('FORUMULATE_POLICY_GATE_KEYS')!;

Deno.serve(async (req) => {
  const jwt = (req.headers.get('Authorization') ?? '').replace(/^Bearer /, '');
  const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!);
  const { data } = await supabase.auth.getUser(jwt);
  const user = data.user;
  if (!user) return new Response('Unauthorized', { status: 401 });

  if (req.method === 'GET') {
    const ticket = await signPolicyTicket({ orgId, spaceId, subject: user.id, event: 'sign_in', email: user.email }, keys);
    return Response.json({ ticket });
  }

  const { token, tokenType } = (await req.json()) as { token: string; tokenType: 'acceptance' | 'receipt' };
  if (tokenType === 'receipt') {
    // Accepted on the sign-up form, before this user existed.
    await forumulate.policies.claim({ orgId, spaceId, receipt: token, subject: user.id, email: user.email });
    return Response.json({ ok: true });
  }
  const check = await verifyAcceptanceToken(token, { keys, subject: user.id, spaceId, requireSatisfied: true });
  return check.valid ? Response.json({ ok: true }) : Response.json({ error: check.reason }, { status: 403 });
});

Firebase

Two blocking functions: beforeUserCreated starts every new account with policies_ok: false, and beforeUserSignedIn sets the claim from the user's status at each sign-in. Security rules and your backend read the claim. Two callable functions hand out a ticket and record the acceptance.

// functions/src/index.ts
import { beforeUserCreated, beforeUserSignedIn } from 'firebase-functions/v2/identity';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { defineSecret } from 'firebase-functions/params';
import { initializeApp } from 'firebase-admin/app';
import { getAuth } from 'firebase-admin/auth';
import { ForumulateClient, signPolicyTicket, verifyAcceptanceToken } from '@forumulate/sdk';

initializeApp();
const FORUMULATE_TOKEN = defineSecret('FORUMULATE_TOKEN');
const GATE_KEYS = defineSecret('FORUMULATE_POLICY_GATE_KEYS');
const orgId = process.env.FORUMULATE_ORG!;
const spaceId = process.env.FORUMULATE_LEGAL_SPACE!;
const client = () => new ForumulateClient({ baseUrl: 'https://forumulate.com/api', token: FORUMULATE_TOKEN.value() });

export const policyOnCreate = beforeUserCreated(() => ({
  // Nobody has accepted anything before their account exists.
  customClaims: { policies_ok: false },
}));

export const policyOnSignIn = beforeUserSignedIn({ secrets: [FORUMULATE_TOKEN] }, async (event) => {
  const uid = event.data!.uid;
  const status = await client().policies.status({ orgId, spaceId, subject: uid, event: 'sign_in' });
  // Keep the user's other claims: customClaims replaces them all.
  return { customClaims: { ...(event.data!.customClaims ?? {}), policies_ok: status.satisfied } };
});

export const policyTicket = onCall({ secrets: [GATE_KEYS] }, async (req) => {
  if (!req.auth) throw new HttpsError('unauthenticated', 'Sign in first.');
  const ticket = await signPolicyTicket(
    { orgId, spaceId, subject: req.auth.uid, event: 'sign_in', email: req.auth.token.email },
    GATE_KEYS.value(),
  );
  return { ticket };
});

export const acceptPolicies = onCall({ secrets: [GATE_KEYS, FORUMULATE_TOKEN] }, async (req) => {
  if (!req.auth) throw new HttpsError('unauthenticated', 'Sign in first.');
  const { token, tokenType } = req.data as { token: string; tokenType: 'acceptance' | 'receipt' };
  if (tokenType === 'receipt') {
    await client().policies.claim({ orgId, spaceId, receipt: token, subject: req.auth.uid, email: req.auth.token.email });
  } else {
    const check = await verifyAcceptanceToken(token, {
      keys: GATE_KEYS.value(),
      subject: req.auth.uid,
      spaceId,
      requireSatisfied: true,
    });
    if (!check.valid) throw new HttpsError('permission-denied', `Policies not accepted (${check.reason}).`);
  }
  const user = await getAuth().getUser(req.auth.uid);
  await getAuth().setCustomUserClaims(req.auth.uid, { ...(user.customClaims ?? {}), policies_ok: true });
  return { ok: true };
});

In the app, after sign-in:

import { getAuth } from 'firebase/auth';
import { getFunctions, httpsCallable } from 'firebase/functions';

declare const ForumulatePolicies: {
  require(o: object): Promise<{ token: string | null; tokenType: 'acceptance' | 'receipt' | 'none' }>;
};

export async function ensurePolicies(): Promise<void> {
  const user = getAuth().currentUser!;
  const { claims } = await user.getIdTokenResult();
  if (claims.policies_ok === true) return;
  const functions = getFunctions();
  const { data } = await httpsCallable<unknown, { ticket: string }>(functions, 'policyTicket')();
  const result = await ForumulatePolicies.require({ org: 'ORG_ID', space: 'LEGAL_SPACE_ID', event: 'sign_in', ticket: data.ticket });
  if (result.tokenType !== 'none') {
    await httpsCallable(functions, 'acceptPolicies')({ token: result.token, tokenType: result.tokenType });
  }
  await user.getIdToken(true); // a fresh ID token carries policies_ok: true
}

And in Firestore security rules:

match /projects/{projectId} {
  allow read, write: if request.auth != null && request.auth.token.policies_ok == true;
}

Auth.js (NextAuth)

The signIn callback checks the user's status. If something is pending, it sends the user to the hosted page; once they accept, they sign in again and the check passes.

// auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { ForumulateClient, signPolicyTicket } from '@forumulate/sdk';

const forumulate = ForumulateClient.fromEnv();
const space = { orgId: process.env.FORUMULATE_ORG!, spaceId: process.env.FORUMULATE_LEGAL_SPACE! };

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [GitHub],
  callbacks: {
    async signIn({ user, account }) {
      // The id your app knows the user by. With a database adapter that is user.id.
      const subject = user.id ?? `${account?.provider}:${account?.providerAccountId}`;
      const status = await forumulate.policies.status({ ...space, subject, event: 'sign_in' });
      if (status.satisfied) return true;
      const ticket = await signPolicyTicket(
        {
          ...space,
          subject,
          event: 'sign_in',
          email: user.email ?? undefined,
          returnUrl: `${process.env.AUTH_URL}/policies/return`,
        },
        process.env.FORUMULATE_POLICY_GATE_KEYS!,
      );
      // An app path: Auth.js only redirects to its own origin by default.
      return `/policies/start?ticket=${encodeURIComponent(ticket)}`;
    },
  },
});
// app/policies/start/route.ts: on to the hosted page
import { policyGatePageUrl } from '@forumulate/sdk';

export function GET(req: Request) {
  const ticket = new URL(req.url).searchParams.get('ticket') ?? '';
  return Response.redirect(
    policyGatePageUrl({ orgId: process.env.FORUMULATE_ORG!, spaceId: process.env.FORUMULATE_LEGAL_SPACE!, ticket }),
    302,
  );
}
// app/policies/return/route.ts: back from the hosted page
import { readPolicyReturn, verifyAcceptanceToken } from '@forumulate/sdk';

export async function GET(req: Request) {
  const back = readPolicyReturn(req.url);
  if (back.error || !back.token) return Response.redirect(new URL('/policies/declined', req.url), 302);
  const check = await verifyAcceptanceToken(back.token, {
    keys: process.env.FORUMULATE_POLICY_GATE_KEYS,
    spaceId: process.env.FORUMULATE_LEGAL_SPACE,
    requireSatisfied: true,
  });
  // Nothing is granted here: the acceptance is recorded, and the signIn
  // callback will see it when the user signs in again.
  const next = check.valid ? '/api/auth/signin' : '/policies/declined';
  return Response.redirect(new URL(next, req.url), 302);
}

Your own backend (Express or Next.js)

Sign-up claims the receipt the form's widget produced. Sign-in checks status and answers 403 policies_required with a ticket, so the front end can show the gate and retry.

import express from 'express';
import { ForumulateClient, signPolicyTicket, verifyAcceptanceToken } from '@forumulate/sdk';

const app = express();
app.use(express.json());
const forumulate = ForumulateClient.fromEnv();
const orgId = process.env.FORUMULATE_ORG!;
const spaceId = process.env.FORUMULATE_LEGAL_SPACE!;
const keys = process.env.FORUMULATE_POLICY_GATE_KEYS!;

// Your own user store; swap in your database.
const users = new Map<string, { id: string; email: string; password: string }>();

app.post('/signup', async (req, res) => {
  const { email, password, forumulate_policy_token: receipt } = req.body;
  const check = await verifyAcceptanceToken(receipt, { keys, use: 'receipt', spaceId, requireSatisfied: true });
  if (!check.valid) return res.status(400).json({ error: 'policies_required', reason: check.reason });

  const user = { id: crypto.randomUUID(), email, password };
  users.set(email, user);
  await forumulate.policies.claim({
    orgId,
    spaceId,
    receipt,
    subject: user.id,
    email,
  });
  res.status(201).json({ id: user.id });
});

app.post('/login', async (req, res) => {
  const user = users.get(req.body.email);
  if (!user || user.password !== req.body.password) return res.status(401).end();

  if (req.body.policyToken) {
    const check = await verifyAcceptanceToken(req.body.policyToken, { keys, subject: user.id, requireSatisfied: true });
    if (!check.valid) return res.status(403).json({ error: 'policies_required', reason: check.reason });
  } else {
    const status = await forumulate.policies.status({ orgId, spaceId, subject: user.id, event: 'sign_in' });
    if (!status.satisfied) {
      const ticket = await signPolicyTicket({ orgId, spaceId, subject: user.id, event: 'sign_in', email: user.email }, keys);
      return res.status(403).json({ error: 'policies_required', pending: status.pending, ticket });
    }
  }
  res.json({ session: 'start your session here' });
});

app.listen(3000);

The front end retries with the token the gate hands back:

import { usePolicyGate } from '@forumulate/react';

export function useLogin() {
  const gate = usePolicyGate({ org: 'ORG_ID', space: 'LEGAL_SPACE_ID' });
  return async (email: string, password: string) => {
    const post = (body: object) =>
      fetch('/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
    let res = await post({ email, password });
    if (res.status === 403) {
      const { ticket } = await res.json();
      const { token } = await gate.require({ event: 'sign_in', ticket, presentation: 'modal' });
      res = await post({ email, password, policyToken: token });
    }
    return res.ok;
  };
}

The same check as a Next.js route handler:

// app/api/login/route.ts
import { ForumulateClient, signPolicyTicket } from '@forumulate/sdk';

const forumulate = ForumulateClient.fromEnv();

async function authenticate(email: string, password: string): Promise<{ id: string } | null> {
  // your user lookup and password check
  return email && password ? { id: email } : null;
}

export async function POST(req: Request) {
  const { email, password } = await req.json();
  const user = await authenticate(email, password);
  if (!user) return new Response(null, { status: 401 });
  const space = { orgId: process.env.FORUMULATE_ORG!, spaceId: process.env.FORUMULATE_LEGAL_SPACE! };
  const status = await forumulate.policies.status({ ...space, subject: user.id, event: 'sign_in' });
  if (!status.satisfied) {
    const ticket = await signPolicyTicket({ ...space, subject: user.id, event: 'sign_in' }, process.env.FORUMULATE_POLICY_GATE_KEYS!);
    return Response.json({ error: 'policies_required', ticket }, { status: 403 });
  }
  return Response.json({ ok: true });
}

Mobile apps and pages without JavaScript

Use the hosted page. Add your app's scheme (exampleapp://policy) or a universal link to page.returnUrls, then:

  1. your server returns the page URL for the signed-in user;

  2. the app opens it in the system browser session (ASWebAuthenticationSession on iOS, Custom Tabs on Android);

  3. the page returns to exampleapp://policy?policy_token=…&state=…;

  4. the app posts the token to your server, which verifies it.

import express from 'express';
import { policyGatePageUrl, signPolicyTicket, verifyAcceptanceToken } from '@forumulate/sdk';

const app = express();
app.use(express.json());
const orgId = process.env.FORUMULATE_ORG!;
const spaceId = process.env.FORUMULATE_LEGAL_SPACE!;
const keys = process.env.FORUMULATE_POLICY_GATE_KEYS!;

// Your session middleware puts the signed-in user here.
type Authed = express.Request & { user: { id: string; email: string } };

app.post('/policies/start', async (req, res) => {
  const { user } = req as Authed;
  const state = crypto.randomUUID();
  const ticket = await signPolicyTicket(
    { orgId, spaceId, subject: user.id, event: 'sign_in', returnUrl: 'exampleapp://policy', state, sourceApp: 'ios' },
    keys,
  );
  res.json({ url: policyGatePageUrl({ orgId, spaceId, ticket }), state });
});

app.post('/policies/finish', async (req, res) => {
  const { user } = req as Authed;
  const check = await verifyAcceptanceToken(req.body.token, { keys, subject: user.id, requireSatisfied: true });
  res.status(check.valid ? 204 : 403).end();
});

On iOS:

import AuthenticationServices

func acceptPolicies(url: URL, expectedState: String, finish: @escaping (String) -> Void) {
    let session = ASWebAuthenticationSession(url: url, callbackURLScheme: "exampleapp") { callback, _ in
        guard let callback,
              let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems,
              items.first(where: { $0.name == "state" })?.value == expectedState,
              let token = items.first(where: { $0.name == "policy_token" })?.value
        else { return } // declined, or cancelled: stay signed out
        finish(token) // POST it to /policies/finish
    }
    session.start()
}

A server-rendered site with no JavaScript does the same with a redirect: send the browser to the page URL, and read the token on your return route with readPolicyReturn.

Atlas

If Atlas signs your users in, connect your Legal space as Atlas's policy source instead: Atlas then gates sign-up and sign-in on your published policies itself, with no code in your app. See Policy source.

To read the same feed from anything else, or to react to new versions:

import { ForumulateClient, verifyPolicyWebhook } from '@forumulate/sdk';

const forumulate = ForumulateClient.fromEnv();
const secret = process.env.POLICY_FEED_SECRET!; // the link's whsec_… secret from Policy sources

export async function currentPolicies() {
  const { policies } = await forumulate.policies.list({ spaceId: process.env.FORUMULATE_LEGAL_SPACE!, secret });
  return policies.map((p) => ({ key: p.key, version: p.versions[0]?.version }));
}

// POST /hooks/policies: the link's webhook URL. Pass the RAW body.
export async function onPolicyWebhook(headers: Headers, rawBody: string) {
  const event = await verifyPolicyWebhook(headers, rawBody, secret);
  if (event.type === 'policy.published') console.log(`${event.key} ${event.version} published`);
  if (event.type === 'policy.retired') console.log(`${event.key} retired`);
}
The Forumulate teamUpdated