@forumulate/sdk 0.7.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/README.md +39 -0
- package/dist/cache.d.ts +56 -0
- package/dist/cache.js +34 -0
- package/dist/http.d.ts +1 -1
- package/dist/http.js +1 -1
- package/dist/index.d.ts +82 -1
- package/dist/index.js +14 -0
- package/dist/operations.gen.d.ts +1315 -24
- package/dist/operations.gen.js +243 -40
- package/dist/policies.d.ts +264 -0
- package/dist/policies.js +93 -0
- package/dist/policy-crypto.d.ts +5 -0
- package/dist/policy-crypto.js +31 -0
- package/dist/policy-feed.d.ts +139 -0
- package/dist/policy-feed.js +249 -0
- package/dist/policy-tickets.d.ts +55 -0
- package/dist/policy-tickets.js +75 -0
- package/dist/policy-tokens.d.ts +188 -0
- package/dist/policy-tokens.js +251 -0
- package/dist/schema.d.ts +2 -2
- package/package.json +2 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PolicyWebhookError = void 0;
|
|
4
|
+
exports.policySecretKey = policySecretKey;
|
|
5
|
+
exports.policyFeedSigningString = policyFeedSigningString;
|
|
6
|
+
exports.signPolicyFeedRequest = signPolicyFeedRequest;
|
|
7
|
+
exports.verifyPolicyFeedRequest = verifyPolicyFeedRequest;
|
|
8
|
+
exports.verifyPolicyWebhook = verifyPolicyWebhook;
|
|
9
|
+
exports.signPolicyWebhook = signPolicyWebhook;
|
|
10
|
+
/**
|
|
11
|
+
* Forumulate policy feed v1, both directions — so any platform can CONSUME a
|
|
12
|
+
* Legal space's policies, or IMPLEMENT the same protocol itself.
|
|
13
|
+
*
|
|
14
|
+
* feed request consumer → source. `GET …/policies`, signed with three
|
|
15
|
+
* headers, `forumulate-id`, `forumulate-timestamp` (Unix
|
|
16
|
+
* MILLISECONDS) and `forumulate-signature`, or the same three
|
|
17
|
+
* as `atlas-*` (Atlas's spelling; the source takes both).
|
|
18
|
+
* Signature: `v1,` + base64(HMAC-SHA256(key,
|
|
19
|
+
* "{id}.{timestamp}.GET\n{path}")), `{path}` being the path
|
|
20
|
+
* plus query exactly as requested — with `/api` on
|
|
21
|
+
* forumulate.com. Several space-separated `v1,` values during
|
|
22
|
+
* a rotation; any one matching is enough.
|
|
23
|
+
* webhook source → consumer. Standard Webhooks: `webhook-id`,
|
|
24
|
+
* `webhook-timestamp` (Unix SECONDS), `webhook-signature:
|
|
25
|
+
* v1,<base64 HMAC-SHA256(key, "{id}.{timestamp}.{body}")>`.
|
|
26
|
+
* Bodies: `policy.published` {key, version} and
|
|
27
|
+
* `policy.retired` {key}. A webhook carries no policy text on
|
|
28
|
+
* purpose: the consumer fetches the feed for that.
|
|
29
|
+
*
|
|
30
|
+
* THE KEY. A secret is `whsec_` + base64 of 32 random bytes (the Standard
|
|
31
|
+
* Webhooks spelling), and the HMAC key is the DECODED bytes. A secret without
|
|
32
|
+
* that prefix is used as its own UTF-8 bytes. The source also accepts a feed
|
|
33
|
+
* request signed with a `whsec_` secret's raw UTF-8 bytes, because which of
|
|
34
|
+
* the two an HMAC call gets is the commonest mistake; the verifier below does
|
|
35
|
+
* the same, the signer always uses the decoded key.
|
|
36
|
+
*
|
|
37
|
+
* The server half lives in `packages/shared/src/policy-feed` (node:crypto);
|
|
38
|
+
* `policy-feed.test.ts` checks this file against it. WebCrypto here.
|
|
39
|
+
*/
|
|
40
|
+
const policy_crypto_1 = require("./policy-crypto");
|
|
41
|
+
const enc = new TextEncoder();
|
|
42
|
+
const SECRET_PREFIX = 'whsec_';
|
|
43
|
+
/** Five minutes, both for the feed's millisecond clock and the webhook's seconds. */
|
|
44
|
+
const DEFAULT_TOLERANCE_SECONDS = 300;
|
|
45
|
+
function bytesToBase64(bytes) {
|
|
46
|
+
let s = '';
|
|
47
|
+
for (const b of bytes)
|
|
48
|
+
s += String.fromCharCode(b);
|
|
49
|
+
return btoa(s);
|
|
50
|
+
}
|
|
51
|
+
function base64ToBytes(s) {
|
|
52
|
+
try {
|
|
53
|
+
const bin = atob(s);
|
|
54
|
+
const out = new Uint8Array(bin.length);
|
|
55
|
+
for (let i = 0; i < bin.length; i++)
|
|
56
|
+
out[i] = bin.charCodeAt(i);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The HMAC key a secret stands for: the decoded bytes of a `whsec_` secret, else its UTF-8. */
|
|
64
|
+
function policySecretKey(secret) {
|
|
65
|
+
if (secret.startsWith(SECRET_PREFIX)) {
|
|
66
|
+
const raw = base64ToBytes(secret.slice(SECRET_PREFIX.length));
|
|
67
|
+
if (raw && raw.length > 0)
|
|
68
|
+
return raw;
|
|
69
|
+
}
|
|
70
|
+
return enc.encode(secret);
|
|
71
|
+
}
|
|
72
|
+
async function hmacBase64(key, data) {
|
|
73
|
+
const k = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
74
|
+
return bytesToBase64(new Uint8Array(await crypto.subtle.sign('HMAC', k, enc.encode(data))));
|
|
75
|
+
}
|
|
76
|
+
/** The `v1,` values of a signature header (space separated), at most eight. */
|
|
77
|
+
function signatureValues(header) {
|
|
78
|
+
return String(header ?? '')
|
|
79
|
+
.split(/\s+/)
|
|
80
|
+
.filter((v) => v.startsWith('v1,'))
|
|
81
|
+
.map((v) => v.slice(3))
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.slice(0, 8);
|
|
84
|
+
}
|
|
85
|
+
function sameString(a, b) {
|
|
86
|
+
return (0, policy_crypto_1.equalBytes)(enc.encode(a), enc.encode(b));
|
|
87
|
+
}
|
|
88
|
+
function headerOf(headers, name) {
|
|
89
|
+
if (typeof headers.get === 'function') {
|
|
90
|
+
return headers.get(name);
|
|
91
|
+
}
|
|
92
|
+
const rec = headers;
|
|
93
|
+
const hit = Object.keys(rec).find((k) => k.toLowerCase() === name);
|
|
94
|
+
const v = hit === undefined ? undefined : rec[hit];
|
|
95
|
+
return Array.isArray(v) ? v.join(' ') : (v ?? null);
|
|
96
|
+
}
|
|
97
|
+
function randomId() {
|
|
98
|
+
const c = globalThis.crypto;
|
|
99
|
+
return c?.randomUUID ? `req_${c.randomUUID()}` : `req_${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
|
100
|
+
}
|
|
101
|
+
/* ------------------------------------------------------------------ *
|
|
102
|
+
* Feed requests
|
|
103
|
+
* ------------------------------------------------------------------ */
|
|
104
|
+
/** The exact string a feed request signs. */
|
|
105
|
+
function policyFeedSigningString(id, timestamp, path) {
|
|
106
|
+
return `${id}.${timestamp}.GET\n${path}`;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The three headers that sign one feed request. Use a fresh call per request:
|
|
110
|
+
* the id is single-use, so a retry must be signed again.
|
|
111
|
+
*
|
|
112
|
+
* ```ts
|
|
113
|
+
* const path = `/api/policy-feed/spaces/${spaceId}/policies`;
|
|
114
|
+
* const headers = await signPolicyFeedRequest({ secret: process.env.POLICY_FEED_SECRET!, path });
|
|
115
|
+
* const list = await (await fetch(`https://forumulate.com${path}`, { headers })).json();
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
async function signPolicyFeedRequest(input) {
|
|
119
|
+
const p = input.prefix ?? 'forumulate';
|
|
120
|
+
const id = input.id ?? randomId();
|
|
121
|
+
const ts = String(Math.floor(input.timestamp ?? Date.now()));
|
|
122
|
+
const secrets = typeof input.secret === 'string' ? [input.secret] : input.secret;
|
|
123
|
+
if (!secrets.length || secrets.some((s) => !s))
|
|
124
|
+
throw new TypeError('signPolicyFeedRequest needs a secret');
|
|
125
|
+
const data = policyFeedSigningString(id, ts, input.path);
|
|
126
|
+
const sigs = await Promise.all(secrets.map(async (s) => `v1,${await hmacBase64(policySecretKey(s), data)}`));
|
|
127
|
+
return { [`${p}-id`]: id, [`${p}-timestamp`]: ts, [`${p}-signature`]: sigs.join(' ') };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Check a signed feed request — the half a policy SOURCE implements. Either
|
|
131
|
+
* header spelling (`forumulate-*` or `atlas-*`) is read.
|
|
132
|
+
*/
|
|
133
|
+
async function verifyPolicyFeedRequest(input) {
|
|
134
|
+
let id = null;
|
|
135
|
+
let ts = null;
|
|
136
|
+
let sig = null;
|
|
137
|
+
for (const p of ['forumulate', 'atlas']) {
|
|
138
|
+
id = headerOf(input.headers, `${p}-id`);
|
|
139
|
+
ts = headerOf(input.headers, `${p}-timestamp`);
|
|
140
|
+
sig = headerOf(input.headers, `${p}-signature`);
|
|
141
|
+
if (id && ts && sig)
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
if (!id || !ts || !sig)
|
|
145
|
+
return { valid: false, reason: 'missing_headers' };
|
|
146
|
+
const now = input.now ?? Date.now();
|
|
147
|
+
// Digits only: `1e12`, hex or a float is refused rather than coerced.
|
|
148
|
+
if (!/^\d{1,16}$/.test(ts) || Math.abs(Number(ts) - now) > (input.toleranceMs ?? DEFAULT_TOLERANCE_SECONDS * 1000))
|
|
149
|
+
return { valid: false, reason: 'stale_timestamp' };
|
|
150
|
+
const presented = signatureValues(sig);
|
|
151
|
+
const secrets = typeof input.secrets === 'string' ? [input.secrets] : input.secrets;
|
|
152
|
+
const paths = typeof input.path === 'string' ? [input.path] : input.path;
|
|
153
|
+
let ok = false;
|
|
154
|
+
// Every candidate is tried, so the time taken does not say which matched.
|
|
155
|
+
for (const secret of secrets) {
|
|
156
|
+
if (!secret)
|
|
157
|
+
continue;
|
|
158
|
+
for (const key of [policySecretKey(secret), enc.encode(secret)]) {
|
|
159
|
+
for (const path of paths) {
|
|
160
|
+
const expected = await hmacBase64(key, policyFeedSigningString(id, ts, path));
|
|
161
|
+
for (const s of presented)
|
|
162
|
+
if (sameString(s, expected))
|
|
163
|
+
ok = true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (!ok)
|
|
168
|
+
return { valid: false, reason: 'bad_signature' };
|
|
169
|
+
// Checked AFTER the signature, so an unsigned flood cannot fill the replay store.
|
|
170
|
+
if (input.seen && (await input.seen(id)))
|
|
171
|
+
return { valid: false, reason: 'replayed' };
|
|
172
|
+
return { valid: true, id };
|
|
173
|
+
}
|
|
174
|
+
/* ------------------------------------------------------------------ *
|
|
175
|
+
* Webhooks
|
|
176
|
+
* ------------------------------------------------------------------ */
|
|
177
|
+
class PolicyWebhookError extends Error {
|
|
178
|
+
reason;
|
|
179
|
+
constructor(reason, message) {
|
|
180
|
+
super(message);
|
|
181
|
+
this.reason = reason;
|
|
182
|
+
this.name = 'PolicyWebhookError';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
exports.PolicyWebhookError = PolicyWebhookError;
|
|
186
|
+
/**
|
|
187
|
+
* Verify a `policy.published` / `policy.retired` notice and parse it. Throws
|
|
188
|
+
* {@link PolicyWebhookError} — the parse can never run ahead of the check.
|
|
189
|
+
*
|
|
190
|
+
* `body` must be the RAW request body: a re-serialised object has different
|
|
191
|
+
* bytes and never verifies. During a secret rotation pass both secrets.
|
|
192
|
+
* Dedupe on `webhook-id`: a retry of one event reuses it.
|
|
193
|
+
*
|
|
194
|
+
* ```ts
|
|
195
|
+
* const event = await verifyPolicyWebhook(req.headers, rawBody, process.env.POLICY_FEED_SECRET!);
|
|
196
|
+
* if (event.type === 'policy.published') await refreshPolicies(event.key);
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
async function verifyPolicyWebhook(headers, body, secret, opts = {}) {
|
|
200
|
+
const id = headerOf(headers, 'webhook-id');
|
|
201
|
+
const ts = headerOf(headers, 'webhook-timestamp');
|
|
202
|
+
const sig = headerOf(headers, 'webhook-signature');
|
|
203
|
+
if (!id || !ts || !sig) {
|
|
204
|
+
throw new PolicyWebhookError('missing_headers', 'webhook-id, webhook-timestamp and webhook-signature are required');
|
|
205
|
+
}
|
|
206
|
+
const tolerance = opts.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
|
|
207
|
+
if (!/^\d{1,12}$/.test(ts))
|
|
208
|
+
throw new PolicyWebhookError('stale_timestamp', 'webhook-timestamp is not Unix seconds');
|
|
209
|
+
if (tolerance > 0) {
|
|
210
|
+
const now = Math.floor((opts.now ?? Date.now()) / 1000);
|
|
211
|
+
if (Math.abs(now - Number(ts)) > tolerance) {
|
|
212
|
+
throw new PolicyWebhookError('stale_timestamp', 'webhook-timestamp is outside the tolerance window');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const text = typeof body === 'string' ? body : new TextDecoder().decode(body);
|
|
216
|
+
const presented = signatureValues(sig);
|
|
217
|
+
const secrets = typeof secret === 'string' ? [secret] : secret;
|
|
218
|
+
let ok = false;
|
|
219
|
+
for (const s of secrets) {
|
|
220
|
+
if (!s)
|
|
221
|
+
continue;
|
|
222
|
+
const expected = await hmacBase64(policySecretKey(s), `${id}.${ts}.${text}`);
|
|
223
|
+
for (const p of presented)
|
|
224
|
+
if (sameString(p, expected))
|
|
225
|
+
ok = true;
|
|
226
|
+
}
|
|
227
|
+
if (!ok)
|
|
228
|
+
throw new PolicyWebhookError('bad_signature', 'Policy webhook signature did not verify (wrong secret or altered body)');
|
|
229
|
+
let event;
|
|
230
|
+
try {
|
|
231
|
+
event = JSON.parse(text);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
throw new PolicyWebhookError('bad_body', 'Policy webhook body is not JSON');
|
|
235
|
+
}
|
|
236
|
+
if (!event || (event.type !== 'policy.published' && event.type !== 'policy.retired') || typeof event.key !== 'string') {
|
|
237
|
+
throw new PolicyWebhookError('bad_body', 'Not a policy.published or policy.retired notice');
|
|
238
|
+
}
|
|
239
|
+
return event;
|
|
240
|
+
}
|
|
241
|
+
/** Standard Webhooks headers for one notice — the SENDING half, for a platform implementing the protocol. */
|
|
242
|
+
async function signPolicyWebhook(input) {
|
|
243
|
+
const id = input.id ?? `msg_${randomId().slice(4)}`;
|
|
244
|
+
const ts = String(Math.floor(input.timestamp ?? Date.now() / 1000));
|
|
245
|
+
const secrets = typeof input.secret === 'string' ? [input.secret] : input.secret;
|
|
246
|
+
const sigs = await Promise.all(secrets.map(async (s) => `v1,${await hmacBase64(policySecretKey(s), `${id}.${ts}.${input.body}`)}`));
|
|
247
|
+
return { 'webhook-id': id, 'webhook-timestamp': ts, 'webhook-signature': sigs.join(' ') };
|
|
248
|
+
}
|
|
249
|
+
//# sourceMappingURL=policy-feed.js.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type PolicyGateEvent, type PolicyGateKey } from './policy-tokens';
|
|
2
|
+
export interface PolicyTicketInput {
|
|
3
|
+
orgId: string;
|
|
4
|
+
/** The Legal space. */
|
|
5
|
+
spaceId: string;
|
|
6
|
+
/** Your id for the user. Opaque to Forumulate; up to 200 characters. */
|
|
7
|
+
subject: string;
|
|
8
|
+
event: PolicyGateEvent;
|
|
9
|
+
/** Hashed here, before it leaves your server; never sent in clear. */
|
|
10
|
+
email?: string;
|
|
11
|
+
/** The email's hash instead (`policyEmailHash`), e.g. from a ticket you verified. */
|
|
12
|
+
emailHash?: string;
|
|
13
|
+
/** Your name for the app (`web`, `ios`), recorded as `sourceApp`. */
|
|
14
|
+
sourceApp?: string;
|
|
15
|
+
/** Hosted page only: where to send the reader back. Must match the space's `page.returnUrls`. */
|
|
16
|
+
returnUrl?: string;
|
|
17
|
+
/** Hosted page only: echoed back verbatim, like OAuth `state`. */
|
|
18
|
+
state?: string;
|
|
19
|
+
locale?: string;
|
|
20
|
+
/** 30–3600 seconds. Default 600. */
|
|
21
|
+
ttlSeconds?: number;
|
|
22
|
+
/** Unix seconds; defaults to now. */
|
|
23
|
+
now?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Sign a ticket with a key of the space's keyring — no call to Forumulate.
|
|
27
|
+
* Hand it to the widget (`ticket` attribute / prop) or put it on the hosted
|
|
28
|
+
* page's URL with {@link policyGatePageUrl}.
|
|
29
|
+
*/
|
|
30
|
+
export declare function signPolicyTicket(input: PolicyTicketInput, key: PolicyGateKey | string): Promise<string>;
|
|
31
|
+
/**
|
|
32
|
+
* The hosted acceptance page for a ticket that carries a `returnUrl`:
|
|
33
|
+
* `https://forumulate.com/policy/{orgId}/{spaceId}?ticket=…`. Redirect the
|
|
34
|
+
* user there; they come back to `returnUrl` with `policy_token` (or
|
|
35
|
+
* `policy_error`) and your `state` — read it with {@link readPolicyReturn}.
|
|
36
|
+
*/
|
|
37
|
+
export declare function policyGatePageUrl(input: {
|
|
38
|
+
orgId: string;
|
|
39
|
+
spaceId: string;
|
|
40
|
+
ticket: string;
|
|
41
|
+
/** The site the page is served from. Default `https://forumulate.com`. */
|
|
42
|
+
site?: string;
|
|
43
|
+
}): string;
|
|
44
|
+
export interface PolicyReturn {
|
|
45
|
+
/** The acceptance token, when the user accepted (or there was nothing to accept). */
|
|
46
|
+
token: string | null;
|
|
47
|
+
tokenType: 'acceptance' | 'receipt' | null;
|
|
48
|
+
/** `declined` when a blocking policy was declined. */
|
|
49
|
+
error: string | null;
|
|
50
|
+
/** Your `state`, verbatim. Compare it with what you stored before redirecting. */
|
|
51
|
+
state: string | null;
|
|
52
|
+
}
|
|
53
|
+
/** Read the query the hosted page appended to your return URL. Verify `token` before trusting it. */
|
|
54
|
+
export declare function readPolicyReturn(url: string | URL | URLSearchParams): PolicyReturn;
|
|
55
|
+
//# sourceMappingURL=policy-tickets.d.ts.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.signPolicyTicket = signPolicyTicket;
|
|
4
|
+
exports.policyGatePageUrl = policyGatePageUrl;
|
|
5
|
+
exports.readPolicyReturn = readPolicyReturn;
|
|
6
|
+
/**
|
|
7
|
+
* Tickets and the hosted page round trip: the half of the policy gate your
|
|
8
|
+
* server SIGNS. See `./policy-tokens` for the format and the verifiers.
|
|
9
|
+
*/
|
|
10
|
+
const policy_crypto_1 = require("./policy-crypto");
|
|
11
|
+
const policy_tokens_1 = require("./policy-tokens");
|
|
12
|
+
const enc = new TextEncoder();
|
|
13
|
+
/**
|
|
14
|
+
* Sign a ticket with a key of the space's keyring — no call to Forumulate.
|
|
15
|
+
* Hand it to the widget (`ticket` attribute / prop) or put it on the hosted
|
|
16
|
+
* page's URL with {@link policyGatePageUrl}.
|
|
17
|
+
*/
|
|
18
|
+
async function signPolicyTicket(input, key) {
|
|
19
|
+
const k = typeof key === 'string' ? ((0, policy_tokens_1.parsePolicyGateKeys)(key)[0] ?? null) : key;
|
|
20
|
+
if (!k?.secret)
|
|
21
|
+
throw new TypeError('signPolicyTicket needs a key: { kid, secret } or "pgk_…:pgs_…"');
|
|
22
|
+
const ttl = Math.min(Math.max(Math.floor(input.ttlSeconds ?? 600), 30), policy_tokens_1.POLICY_TICKET_MAX_SECONDS);
|
|
23
|
+
const iat = input.now ?? Math.floor(Date.now() / 1000);
|
|
24
|
+
const claims = {
|
|
25
|
+
use: 'ticket',
|
|
26
|
+
aud: policy_tokens_1.POLICY_TOKEN_AUDIENCE,
|
|
27
|
+
org: input.orgId,
|
|
28
|
+
spc: input.spaceId,
|
|
29
|
+
sub: input.subject,
|
|
30
|
+
evt: input.event,
|
|
31
|
+
iat,
|
|
32
|
+
exp: iat + ttl,
|
|
33
|
+
};
|
|
34
|
+
if (input.email)
|
|
35
|
+
claims.eh = await (0, policy_tokens_1.policyEmailHash)(input.email);
|
|
36
|
+
else if (input.emailHash)
|
|
37
|
+
claims.eh = input.emailHash;
|
|
38
|
+
if (input.sourceApp)
|
|
39
|
+
claims.app = input.sourceApp;
|
|
40
|
+
if (input.returnUrl)
|
|
41
|
+
claims.ret = input.returnUrl;
|
|
42
|
+
if (input.state)
|
|
43
|
+
claims.st = input.state;
|
|
44
|
+
if (input.locale)
|
|
45
|
+
claims.loc = input.locale;
|
|
46
|
+
const head = (0, policy_crypto_1.base64UrlEncode)(enc.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT', kid: k.kid })));
|
|
47
|
+
const body = (0, policy_crypto_1.base64UrlEncode)(enc.encode(JSON.stringify(claims)));
|
|
48
|
+
const sig = await (0, policy_tokens_1.hmacBytes)(enc.encode(k.secret), `${head}.${body}`);
|
|
49
|
+
return `${head}.${body}.${(0, policy_crypto_1.base64UrlEncode)(sig)}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The hosted acceptance page for a ticket that carries a `returnUrl`:
|
|
53
|
+
* `https://forumulate.com/policy/{orgId}/{spaceId}?ticket=…`. Redirect the
|
|
54
|
+
* user there; they come back to `returnUrl` with `policy_token` (or
|
|
55
|
+
* `policy_error`) and your `state` — read it with {@link readPolicyReturn}.
|
|
56
|
+
*/
|
|
57
|
+
function policyGatePageUrl(input) {
|
|
58
|
+
const site = (input.site ?? 'https://forumulate.com').replace(/\/+$/, '');
|
|
59
|
+
const e = encodeURIComponent;
|
|
60
|
+
return `${site}/policy/${e(input.orgId)}/${e(input.spaceId)}?ticket=${e(input.ticket)}`;
|
|
61
|
+
}
|
|
62
|
+
/** Read the query the hosted page appended to your return URL. Verify `token` before trusting it. */
|
|
63
|
+
function readPolicyReturn(url) {
|
|
64
|
+
const q = url instanceof URLSearchParams
|
|
65
|
+
? url
|
|
66
|
+
: new URL(String(url), 'https://return.invalid').searchParams;
|
|
67
|
+
const type = q.get('policy_token_type');
|
|
68
|
+
return {
|
|
69
|
+
token: q.get('policy_token'),
|
|
70
|
+
tokenType: type === 'acceptance' || type === 'receipt' ? type : null,
|
|
71
|
+
error: q.get('policy_error'),
|
|
72
|
+
state: q.get('state'),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=policy-tickets.js.map
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
export declare const POLICY_TOKEN_AUDIENCE = "forumulate:policy-gate";
|
|
2
|
+
/** A ticket may live this long at most; the API refuses a longer one. */
|
|
3
|
+
export declare const POLICY_TICKET_MAX_SECONDS = 3600;
|
|
4
|
+
export type PolicyGateEvent = 'sign_up' | 'sign_in';
|
|
5
|
+
export type PolicyTokenUse = 'ticket' | 'acceptance' | 'receipt';
|
|
6
|
+
/** A key of the space's keyring. `kid` may be empty for a bare secret. */
|
|
7
|
+
export interface PolicyGateKey {
|
|
8
|
+
kid: string;
|
|
9
|
+
secret: string;
|
|
10
|
+
}
|
|
11
|
+
/** One accepted version inside an acceptance token or a receipt. */
|
|
12
|
+
export interface PolicyTokenAcceptance {
|
|
13
|
+
/** Policy key (`tos`, `privacy`). */
|
|
14
|
+
k: string;
|
|
15
|
+
/** The version accepted. */
|
|
16
|
+
v: string;
|
|
17
|
+
/** SHA-256 (hex) of exactly the HTML that was shown. */
|
|
18
|
+
h: string;
|
|
19
|
+
/** The presentation it was shown in. */
|
|
20
|
+
p: string;
|
|
21
|
+
/** Whether the reader scrolled to the end. */
|
|
22
|
+
s: boolean;
|
|
23
|
+
/** Unix seconds, on Forumulate's database clock. */
|
|
24
|
+
at: number;
|
|
25
|
+
}
|
|
26
|
+
export interface PolicyTicketClaims {
|
|
27
|
+
use: 'ticket';
|
|
28
|
+
aud: typeof POLICY_TOKEN_AUDIENCE;
|
|
29
|
+
org: string;
|
|
30
|
+
spc: string;
|
|
31
|
+
sub: string;
|
|
32
|
+
evt: PolicyGateEvent;
|
|
33
|
+
eh?: string;
|
|
34
|
+
app?: string;
|
|
35
|
+
ret?: string;
|
|
36
|
+
st?: string;
|
|
37
|
+
loc?: string;
|
|
38
|
+
iat: number;
|
|
39
|
+
exp: number;
|
|
40
|
+
iss?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface PolicyAcceptanceClaims {
|
|
43
|
+
use: 'acceptance' | 'receipt';
|
|
44
|
+
aud: typeof POLICY_TOKEN_AUDIENCE;
|
|
45
|
+
iss: 'forumulate';
|
|
46
|
+
org: string;
|
|
47
|
+
spc: string;
|
|
48
|
+
/** Absent on a receipt. */
|
|
49
|
+
sub?: string;
|
|
50
|
+
evt: PolicyGateEvent;
|
|
51
|
+
/** Nothing blocking is left for this subject and event. */
|
|
52
|
+
ok: boolean;
|
|
53
|
+
/** Keys still blocking. */
|
|
54
|
+
pend: string[];
|
|
55
|
+
acc: PolicyTokenAcceptance[];
|
|
56
|
+
ctx?: {
|
|
57
|
+
ip?: string;
|
|
58
|
+
ua?: string;
|
|
59
|
+
loc?: string;
|
|
60
|
+
app?: string;
|
|
61
|
+
eh?: string;
|
|
62
|
+
};
|
|
63
|
+
jti: string;
|
|
64
|
+
iat: number;
|
|
65
|
+
exp: number;
|
|
66
|
+
}
|
|
67
|
+
export type PolicyTokenFailure = 'malformed' | 'unsupported_algorithm' | 'unknown_key' | 'bad_signature' | 'expired' | 'not_yet_valid' | 'wrong_audience' | 'wrong_issuer' | 'wrong_use' | 'wrong_subject' | 'wrong_space' | 'wrong_org' | 'not_satisfied' | 'no_keys';
|
|
68
|
+
export type VerifiedAcceptance = {
|
|
69
|
+
valid: true;
|
|
70
|
+
claims: PolicyAcceptanceClaims;
|
|
71
|
+
/** The key that signed it. */
|
|
72
|
+
kid: string;
|
|
73
|
+
/** `claims.ok`: nothing blocking is left. */
|
|
74
|
+
satisfied: boolean;
|
|
75
|
+
/** `claims.pend`: the keys still blocking. */
|
|
76
|
+
pending: string[];
|
|
77
|
+
} | {
|
|
78
|
+
valid: false;
|
|
79
|
+
reason: PolicyTokenFailure;
|
|
80
|
+
};
|
|
81
|
+
/** A JSON Web Key Set of symmetric (`kty: "oct"`) keys. */
|
|
82
|
+
export interface PolicyJwks {
|
|
83
|
+
keys: {
|
|
84
|
+
kty: string;
|
|
85
|
+
kid?: string;
|
|
86
|
+
k?: string;
|
|
87
|
+
alg?: string;
|
|
88
|
+
}[];
|
|
89
|
+
}
|
|
90
|
+
export interface VerifyAcceptanceTokenOptions {
|
|
91
|
+
/**
|
|
92
|
+
* The keyring: `[{ kid, secret }]`, or the env-var form
|
|
93
|
+
* `"pgk_a:pgs_…,pgk_b:pgs_…"`. During a rotation keep both keys here; the
|
|
94
|
+
* token's `kid` picks the right one.
|
|
95
|
+
*/
|
|
96
|
+
keys?: readonly PolicyGateKey[] | string;
|
|
97
|
+
/** One bare secret, tried against any `kid`. */
|
|
98
|
+
secret?: string;
|
|
99
|
+
/**
|
|
100
|
+
* A URL serving YOUR keyring as a JWKS of `oct` keys (`k` = base64url of the
|
|
101
|
+
* secret's UTF-8 bytes) — a private endpoint of your own, e.g. in front of a
|
|
102
|
+
* secrets manager. The keys are symmetric, so Forumulate never publishes
|
|
103
|
+
* them; this is for teams that keep secrets out of the environment. Cached
|
|
104
|
+
* for `jwksCacheSeconds` and refetched once on an unknown `kid`.
|
|
105
|
+
*/
|
|
106
|
+
jwksUrl?: string;
|
|
107
|
+
/** The same set, already in hand. */
|
|
108
|
+
jwks?: PolicyJwks;
|
|
109
|
+
/** Headers for the `jwksUrl` request (its own authorization). */
|
|
110
|
+
jwksHeaders?: Record<string, string>;
|
|
111
|
+
/** Default 300. */
|
|
112
|
+
jwksCacheSeconds?: number;
|
|
113
|
+
/** Which kinds to accept. Default `'acceptance'`; add `'receipt'` on a sign-up form. */
|
|
114
|
+
use?: 'acceptance' | 'receipt' | readonly ('acceptance' | 'receipt')[];
|
|
115
|
+
/** The token must be for this user (your id for them). */
|
|
116
|
+
subject?: string;
|
|
117
|
+
/** The token must be for this Legal space. */
|
|
118
|
+
spaceId?: string;
|
|
119
|
+
/** The token must be for this organization. */
|
|
120
|
+
orgId?: string;
|
|
121
|
+
/** Refuse a token with blocking policies still pending (`reason: 'not_satisfied'`). */
|
|
122
|
+
requireSatisfied?: boolean;
|
|
123
|
+
/** Unix seconds; defaults to now. */
|
|
124
|
+
now?: number;
|
|
125
|
+
/** Clock skew tolerated either way, seconds. Default 30. */
|
|
126
|
+
leewaySeconds?: number;
|
|
127
|
+
fetch?: typeof fetch;
|
|
128
|
+
}
|
|
129
|
+
export declare function hmacBytes(key: Uint8Array, data: string): Promise<Uint8Array>;
|
|
130
|
+
/**
|
|
131
|
+
* Parse a keyring from one environment variable: `kid:secret` pairs separated
|
|
132
|
+
* by commas or whitespace (`FORUMULATE_POLICY_GATE_KEYS`). A bare secret is
|
|
133
|
+
* kept with an empty `kid` and tried against every token.
|
|
134
|
+
*/
|
|
135
|
+
export declare function parsePolicyGateKeys(value: string | undefined | null): PolicyGateKey[];
|
|
136
|
+
/** SHA-256 hex of a string's UTF-8 bytes (the content hash). */
|
|
137
|
+
export declare function policySha256Hex(text: string): Promise<string>;
|
|
138
|
+
/** How an email becomes `emailHash`: trimmed, lower-cased, SHA-256 hex. */
|
|
139
|
+
export declare function policyEmailHash(email: string): Promise<string>;
|
|
140
|
+
/** Header and claims, UNVERIFIED — for logs and for picking a key. Never for decisions. */
|
|
141
|
+
export declare function decodePolicyToken(token: string): {
|
|
142
|
+
header: {
|
|
143
|
+
alg?: string;
|
|
144
|
+
kid?: string;
|
|
145
|
+
typ?: string;
|
|
146
|
+
};
|
|
147
|
+
claims: Record<string, unknown>;
|
|
148
|
+
} | null;
|
|
149
|
+
/**
|
|
150
|
+
* Verify an acceptance token (or receipt) OFFLINE, against your copy of the
|
|
151
|
+
* space's keyring. Returns a result rather than throwing, so the reason is in
|
|
152
|
+
* hand for a log line:
|
|
153
|
+
*
|
|
154
|
+
* ```ts
|
|
155
|
+
* const r = await verifyAcceptanceToken(token, {
|
|
156
|
+
* keys: process.env.FORUMULATE_POLICY_GATE_KEYS,
|
|
157
|
+
* subject: user.id,
|
|
158
|
+
* spaceId: process.env.FORUMULATE_LEGAL_SPACE,
|
|
159
|
+
* requireSatisfied: true,
|
|
160
|
+
* });
|
|
161
|
+
* if (!r.valid) return res.status(403).send(r.reason);
|
|
162
|
+
* ```
|
|
163
|
+
*
|
|
164
|
+
* Online verification (no key on your server) is `client.policies.verify`.
|
|
165
|
+
*/
|
|
166
|
+
export declare function verifyAcceptanceToken(token: string, opts: VerifyAcceptanceTokenOptions): Promise<VerifiedAcceptance>;
|
|
167
|
+
/** Key options shared by every verifier. */
|
|
168
|
+
export type PolicyKeyOptions = Pick<VerifyAcceptanceTokenOptions, 'keys' | 'secret' | 'jwks' | 'jwksUrl' | 'jwksHeaders' | 'jwksCacheSeconds' | 'fetch'>;
|
|
169
|
+
/**
|
|
170
|
+
* Verify a TICKET (yours, signed with the same keyring), for a server that
|
|
171
|
+
* receives one back: a bridge between an auth platform's redirect and the
|
|
172
|
+
* hosted page, say. A ticket meant to live longer than an hour is refused.
|
|
173
|
+
*/
|
|
174
|
+
export declare function verifyPolicyTicket(token: string, opts: PolicyKeyOptions & {
|
|
175
|
+
subject?: string;
|
|
176
|
+
spaceId?: string;
|
|
177
|
+
orgId?: string;
|
|
178
|
+
now?: number;
|
|
179
|
+
leewaySeconds?: number;
|
|
180
|
+
}): Promise<{
|
|
181
|
+
valid: true;
|
|
182
|
+
claims: PolicyTicketClaims;
|
|
183
|
+
kid: string;
|
|
184
|
+
} | {
|
|
185
|
+
valid: false;
|
|
186
|
+
reason: PolicyTokenFailure;
|
|
187
|
+
}>;
|
|
188
|
+
//# sourceMappingURL=policy-tokens.d.ts.map
|