@foir/sdk 0.20.0 → 0.21.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/dist/server.js CHANGED
@@ -74,10 +74,10 @@ async function verifyWebhookSignature(payload, signature, secret) {
74
74
 
75
75
  // src/auth/grant-table.generated.ts
76
76
  var GRANT_TABLE = [
77
- ["records:read", true, ["own", "named", "all"], []],
77
+ ["records:read", true, ["own", "all"], []],
78
78
  ["records:create", true, [], []],
79
- ["records:write", true, ["own", "named", "all"], []],
80
- ["records:delete", true, ["own", "named", "all"], []],
79
+ ["records:write", true, ["own", "all"], []],
80
+ ["records:delete", true, ["own", "all"], []],
81
81
  ["records:publish", true, ["own", "all"], []],
82
82
  ["records:attribute", true, [], []],
83
83
  ["search:read", true, [], []],
@@ -93,8 +93,8 @@ var GRANT_TABLE = [
93
93
  ["notifications:consent", false, [], []],
94
94
  ["pushcredentials:read", false, [], []],
95
95
  ["pushcredentials:write", false, [], []],
96
- ["notes:read", false, [], []],
97
- ["notes:write", false, [], []],
96
+ ["notes:read", false, ["own", "all"], []],
97
+ ["notes:write", false, ["own", "all"], []],
98
98
  ["notes:create", false, [], []],
99
99
  ["approvals:read", false, [], []],
100
100
  ["approvals:write", false, [], []],
@@ -151,7 +151,7 @@ var GRANT_TABLE = [
151
151
  ["roles:write", false, [], []],
152
152
  ["projects:read", false, [], []],
153
153
  ["projects:write", false, [], []],
154
- ["project:manage", false, [], ["records:read.all", "records:create", "records:write.all", "records:delete.all", "records:publish.all", "records:attribute", "search:read", "embeddings:read", "embeddings:write", "schemas:read", "drafts:read", "files:read.all", "files:write.all", "files:delete.all", "users:read.all", "identities:read", "users:create", "identities:delete", "users:write.all", "users:delete.all", "notifications:read.all", "notifications:write.all", "notifications:consent", "pushcredentials:read", "pushcredentials:write", "notes:read", "notes:write", "notes:create", "approvals:read", "approvals:write", "rooms:read", "rooms:write", "presence:read", "operations:read", "operations:execute", "operations:write", "appconfig:read", "schedules:read", "schedules:write", "hooks:read", "hooks:write", "auth:login_with_foir", "relyingparties:read", "relyingparties:write", "domains:read", "domains:write", "secrets:read", "secrets:list", "secrets:put", "secrets:delete", "appsecrets:read", "appsecrets:list", "appsecrets:put", "appsecrets:delete", "usersecrets:read", "usersecrets:list", "usersecrets:put", "usersecrets:delete", "opsecrets:read", "opsecrets:write", "settings:read", "settings:write", "designtokens:read", "designtokens:write", "models:read", "models:write", "configs:read", "configs:write", "signinproviders:read", "signinproviders:write", "apps:read", "apps:write", "events:read", "targeting:debug", "visitors:forward", "apikeys:read", "apikeys:write", "organizations:read", "organizations:create", "organizations:delete", "organizations:write", "organizations:reach", "members:read", "members:write", "roles:read", "roles:write", "projects:read", "projects:write"]],
154
+ ["project:manage", false, [], ["records:read.all", "records:create", "records:write.all", "records:delete.all", "records:publish.all", "records:attribute", "search:read", "embeddings:read", "embeddings:write", "schemas:read", "drafts:read", "files:read.all", "files:write.all", "files:delete.all", "users:read.all", "identities:read", "users:create", "identities:delete", "users:write.all", "users:delete.all", "notifications:read.all", "notifications:write.all", "notifications:consent", "pushcredentials:read", "pushcredentials:write", "notes:read.all", "notes:write.all", "notes:create", "approvals:read", "approvals:write", "rooms:read", "rooms:write", "presence:read", "operations:read", "operations:execute", "operations:write", "appconfig:read", "schedules:read", "schedules:write", "hooks:read", "hooks:write", "auth:login_with_foir", "relyingparties:read", "relyingparties:write", "domains:read", "domains:write", "secrets:read", "secrets:list", "secrets:put", "secrets:delete", "appsecrets:read", "appsecrets:list", "appsecrets:put", "appsecrets:delete", "usersecrets:read", "usersecrets:list", "usersecrets:put", "usersecrets:delete", "opsecrets:read", "opsecrets:write", "settings:read", "settings:write", "designtokens:read", "designtokens:write", "models:read", "models:write", "configs:read", "configs:write", "signinproviders:read", "signinproviders:write", "apps:read", "apps:write", "events:read", "targeting:debug", "visitors:forward", "apikeys:read", "apikeys:write", "organizations:read", "organizations:create", "organizations:delete", "organizations:write", "organizations:reach", "members:read", "members:write", "roles:read", "roles:write", "projects:read", "projects:write", "audit:read"]],
155
155
  ["projects:create", false, [], []],
156
156
  ["projects:delete", false, [], []],
157
157
  ["tenants:read", false, [], []],
@@ -167,7 +167,7 @@ var GRANT_TABLE = [
167
167
  ["tenanthooks:read", false, [], []],
168
168
  ["tenanthooks:write", false, [], []],
169
169
  ["apps:install", false, [], []],
170
- ["tenant:manage", false, [], ["project:manage", "projects:create", "projects:delete", "tenants:read", "tenants:write", "tenants:delete", "audit:read", "export:run", "billing:read", "billing:manage", "usage:read", "controlkeys:read", "controlkeys:write", "tenanthooks:read", "tenanthooks:write", "apps:install"]],
170
+ ["tenant:manage", false, [], ["project:manage", "projects:create", "projects:delete", "tenants:read", "tenants:write", "tenants:delete", "export:run", "billing:read", "billing:manage", "usage:read", "controlkeys:read", "controlkeys:write", "tenanthooks:read", "tenanthooks:write", "apps:install"]],
171
171
  ["platform:read", false, [], []],
172
172
  ["platform:manage", false, [], ["tenant:manage", "platform:read", "platform:create_workspace", "users:setup"]],
173
173
  ["platform:create_workspace", false, [], []],
@@ -231,49 +231,6 @@ function parseGrant(s) {
231
231
  return null;
232
232
  }
233
233
 
234
- // src/server/older-claims.generated.ts
235
- var OLDER_CLAIM_TARGET = "{target}";
236
- var OLDER_CAPABILITY_GRANTS = {
237
- "config:read": ["appconfig:read"],
238
- "customers:read": ["users:read.all"],
239
- "customers:read:identity": ["identities:read"],
240
- "customers:read:named": ["users:read.named"],
241
- "customers:write": ["users:create"],
242
- "embeddings:read": ["embeddings:read"],
243
- "embeddings:write": ["embeddings:write"],
244
- "files:delete": ["files:delete"],
245
- "files:list": ["files:read"],
246
- "files:read": ["files:read"],
247
- "files:write": ["files:write"],
248
- "notifications:consent": ["notifications:consent"],
249
- "notifications:read": ["notifications:read"],
250
- "notifications:write": ["notifications:write.named"],
251
- "operations:complete": ["operations:complete"],
252
- "records:attribute": ["records:attribute"],
253
- "records:attribute:{target}": ["records:attribute:{target}"],
254
- "records:delete": [],
255
- "records:delete:{target}": ["records:delete:{target}"],
256
- "records:publish": [],
257
- "records:publish:{target}": ["records:publish:{target}"],
258
- "records:read": [],
259
- "records:read:{target}": ["records:read:{target}"],
260
- "records:write": [],
261
- "records:write:{target}": ["records:write.all:{target}", "records:create:{target}"],
262
- "secrets:delete.app": ["appsecrets:delete"],
263
- "secrets:delete.customer": ["usersecrets:delete"],
264
- "secrets:delete.project": ["secrets:delete"],
265
- "secrets:list.app": ["appsecrets:list"],
266
- "secrets:list.customer": ["usersecrets:list"],
267
- "secrets:list.project": ["secrets:list"],
268
- "secrets:put.app": ["appsecrets:put"],
269
- "secrets:put.customer": ["usersecrets:put"],
270
- "secrets:put.project": ["secrets:put"],
271
- "secrets:read.app": ["appsecrets:read"],
272
- "secrets:read.customer": ["usersecrets:read"],
273
- "secrets:read.project": ["secrets:read"],
274
- "status:write": ["status:write"]
275
- };
276
-
277
234
  // src/server/verify-scoped-token.ts
278
235
  var JWKS_CACHE_TTL_MS = 5 * 60 * 1e3;
279
236
  function getCache() {
@@ -349,49 +306,9 @@ function recordsWriteModels(claims) {
349
306
  function recordsCreateModels(claims) {
350
307
  return modelsFor(claims, "records:create");
351
308
  }
352
- var OLDER_RECORD_LANES = [
353
- ["records:read", "rrm"],
354
- ["records:write", "rwm"],
355
- ["records:delete", "rdm"],
356
- ["records:publish", "rpm"]
357
- ];
358
309
  function strings(value) {
359
310
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
360
311
  }
361
- function worthBefore(capability) {
362
- if (Object.hasOwn(OLDER_CAPABILITY_GRANTS, capability)) return OLDER_CAPABILITY_GRANTS[capability] ?? [];
363
- for (const [form, grants] of Object.entries(OLDER_CAPABILITY_GRANTS)) {
364
- if (!form.endsWith(OLDER_CLAIM_TARGET)) continue;
365
- const prefix = form.slice(0, -OLDER_CLAIM_TARGET.length);
366
- if (capability.startsWith(prefix) && capability.length > prefix.length) {
367
- const model = capability.slice(prefix.length);
368
- return grants.map((grant) => grant.replace(OLDER_CLAIM_TARGET, model));
369
- }
370
- }
371
- return [];
372
- }
373
- var OLDER_WRITE = "records:write";
374
- var OLDER_DELETE = "records:delete";
375
- var OLDER_CREATE = "customers:write";
376
- function grantsOfOlderClaims(payload) {
377
- const cap = strings(payload.cap);
378
- const written = new Set(cap.includes(OLDER_WRITE) ? strings(payload.rwm) : []);
379
- const grants = [];
380
- for (const [marker, claim] of OLDER_RECORD_LANES) {
381
- if (!cap.includes(marker)) continue;
382
- for (const model of strings(payload[claim])) {
383
- if (marker !== OLDER_WRITE && written.has(model)) grants.push(`${marker}.all:${model}`);
384
- else grants.push(...worthBefore(`${marker}:${model}`));
385
- }
386
- }
387
- for (const capability of cap) {
388
- if (!OLDER_RECORD_LANES.some(([marker]) => marker === capability)) grants.push(...worthBefore(capability));
389
- }
390
- if (cap.includes(OLDER_CREATE) && cap.includes(OLDER_DELETE) && strings(payload.rdm).length > 0) {
391
- grants.push("identities:delete");
392
- }
393
- return grants;
394
- }
395
312
  async function verifyScopedToken(token, jwksUrl, options) {
396
313
  return verifyWithKeys(token, () => loadJwks(jwksUrl), void 0, options);
397
314
  }
@@ -432,10 +349,7 @@ async function verifyWithKeys(token, loadKeys, refetchForUnknownKid, options) {
432
349
  } catch {
433
350
  throw new Error("unreadable token payload");
434
351
  }
435
- const claims = {
436
- ...payload,
437
- grants: "grants" in payload ? strings(payload.grants) : grantsOfOlderClaims(payload)
438
- };
352
+ const claims = { ...payload, grants: strings(payload.grants) };
439
353
  const now = Math.floor((options?.now?.() ?? Date.now()) / 1e3);
440
354
  if (claims.iss !== expectedIssuer) throw new Error(`bad iss ${claims.iss}`);
441
355
  if (typeof claims.exp !== "number" || claims.exp < now) {
@@ -444,6 +358,7 @@ async function verifyWithKeys(token, loadKeys, refetchForUnknownKid, options) {
444
358
  if (typeof claims.nbf === "number" && claims.nbf > now + 30) {
445
359
  throw new Error("token not yet valid");
446
360
  }
361
+ if (!("grants" in payload)) throw new Error("the token says no grants");
447
362
  return claims;
448
363
  }
449
364
  function parseContextHeader(raw) {
@@ -879,6 +794,28 @@ function refuseCredentialHeaders(extra) {
879
794
  }
880
795
  }
881
796
  var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
797
+ function sentCoordinate(value) {
798
+ return Math.round(value * 100) / 100;
799
+ }
800
+ async function resolveFoirVisitor(source) {
801
+ const value = typeof source === "function" ? await source() : source;
802
+ if (value === null || value === void 0) return { place: {} };
803
+ if (typeof value === "string") {
804
+ const address2 = value.trim();
805
+ return { address: address2 || void 0, place: {} };
806
+ }
807
+ const address = value.address?.trim();
808
+ const country = value.country?.trim();
809
+ const place = {};
810
+ if (country) place.country = country;
811
+ if (Number.isFinite(value.latitude) && Number.isFinite(value.longitude)) {
812
+ place.location = {
813
+ latitude: sentCoordinate(value.latitude),
814
+ longitude: sentCoordinate(value.longitude)
815
+ };
816
+ }
817
+ return { address: address || void 0, place };
818
+ }
882
819
  function buildFoirHeaders(input) {
883
820
  const headers = {
884
821
  "Content-Type": "application/json"
@@ -1096,12 +1033,26 @@ async function hmacSha256Hex(secret, body) {
1096
1033
  return Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
1097
1034
  }
1098
1035
 
1036
+ // src/transport/key-kind.ts
1037
+ var KEY_PREFIXES = [
1038
+ ["foir_pub_", "publishable"],
1039
+ ["foir_srv_", "server"],
1040
+ ["foir_adm_", "admin"],
1041
+ ["pk_", "publishable"],
1042
+ ["sk_", "older"],
1043
+ ["tc_", "older"]
1044
+ ];
1045
+ function foirKeyKind(apiKey) {
1046
+ if (typeof apiKey !== "string") return void 0;
1047
+ for (const [prefix, kind] of KEY_PREFIXES) {
1048
+ if (apiKey.startsWith(prefix)) return kind;
1049
+ }
1050
+ return void 0;
1051
+ }
1052
+
1099
1053
  // src/data/transport.ts
1100
1054
  async function resolveVisitorAddress(config) {
1101
- const source = config.visitor;
1102
- const value = typeof source === "function" ? await source() : source;
1103
- const address = value?.trim();
1104
- return address ? address : void 0;
1055
+ return (await resolveFoirVisitor(config.visitor)).address;
1105
1056
  }
1106
1057
  async function resolveBearerToken(config) {
1107
1058
  const source = config.bearerToken;
@@ -1538,17 +1489,18 @@ function assertOperatorKey(apiKey) {
1538
1489
  const key = apiKey?.trim() ?? "";
1539
1490
  if (!key) {
1540
1491
  throw new FoirRequestError(
1541
- `[foir] createEventsClient: apiKey is empty \u2014 pass an sk_ secret key holding ${FOIR_EVENTS_SCOPE}.`
1492
+ `[foir] createEventsClient: apiKey is empty. Pass an admin key holding ${FOIR_EVENTS_SCOPE}.`
1542
1493
  );
1543
1494
  }
1544
- if (key.startsWith("pk_")) {
1495
+ const kind = foirKeyKind(key);
1496
+ if (kind === "publishable" || kind === "server") {
1545
1497
  throw new FoirRequestError(
1546
- `[foir] createEventsClient: the event log is an OPERATOR surface and a public (pk_) key can never hold ${FOIR_EVENTS_SCOPE} \u2014 it is not mintable on a public key. Use an sk_ secret key from a server-side runtime; a pk_ key is embeddable in browser bundles, and the log names which customer acted on records the bundle's reader cannot themselves read.`
1498
+ `[foir] createEventsClient: the event log is an OPERATOR surface and a ${kind} key can never hold ${FOIR_EVENTS_SCOPE}: it is not mintable on an app's key. Use an admin key from a machine of your own. The log names which customer acted on records that whoever reads an app's key cannot themselves read.`
1547
1499
  );
1548
1500
  }
1549
- if (!key.startsWith("sk_")) {
1501
+ if (kind === void 0) {
1550
1502
  throw new FoirRequestError(
1551
- `[foir] createEventsClient: apiKey must be an sk_ secret key, got a key starting "${key.slice(0, 4)}". The event log is reachable by an sk_ api key or an admin session only.`
1503
+ `[foir] createEventsClient: apiKey must be an admin key, got a key starting "${key.slice(0, 4)}". The event log is reachable by an admin key or an admin session only.`
1552
1504
  );
1553
1505
  }
1554
1506
  return key;
@@ -273,8 +273,8 @@ export interface FoirKeyHeaderInput {
273
273
  * freshly pushed model changes (new fields, new models) are visible at
274
274
  * runtime — otherwise selections on draft-only fields are silently stripped
275
275
  * and surface as confusing "fragment never used" errors. The header has no
276
- * effect under public keys (the server ignores it), so it is safe to send
277
- * whenever the caller is in preview.
276
+ * effect under an app's key without `drafts:read` (the server ignores it),
277
+ * so it is safe to send whenever the caller is in preview.
278
278
  */
279
279
  draftChannel?: boolean;
280
280
  bearerToken?: string;
@@ -343,8 +343,28 @@ export declare function refuseCredentialHeaders(extra: Readonly<Record<string, s
343
343
  /** The header a server names the visitor it calls for in. */
344
344
  export declare const FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
345
345
  /**
346
- * The address of the visitor a server rendering pages is calling for, or a
347
- * function that resolves it for each request.
346
+ * The visitor a server rendering pages is calling for: who they are to the
347
+ * per-visitor limits, and where they are to location targeting.
348
+ *
349
+ * Each part is what your host says of the visitor, and each is optional. A
350
+ * part left out is not said: with no address the request counts as your
351
+ * server's own, and with no country and no coordinates the visitor is one
352
+ * nobody could place. Where your server is never stands in for where the
353
+ * visitor is.
354
+ */
355
+ export interface FoirVisitor {
356
+ /** The visitor's address, sent as `X-Foir-Visitor-IP`. */
357
+ address?: string | null;
358
+ /** The visitor's country, as an ISO 3166-1 alpha-2 code such as `GB`. */
359
+ country?: string | null;
360
+ /** The visitor's latitude, in decimal degrees. Sent with `longitude`, or not at all. */
361
+ latitude?: number | null;
362
+ /** The visitor's longitude, in decimal degrees. Sent with `latitude`, or not at all. */
363
+ longitude?: number | null;
364
+ }
365
+ /**
366
+ * The visitor a server rendering pages is calling for, or a function that
367
+ * resolves them for each request. A string is the visitor's address alone.
348
368
  *
349
369
  * The SDK does not read it off a request, because which request header holds
350
370
  * the visitor's real address, and whether a visitor can forge it, depends on
@@ -352,7 +372,26 @@ export declare const FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
352
372
  * strips; that value, and only that, belongs here. A function that yields
353
373
  * nothing sends nothing, and the API counts the request as your server's own.
354
374
  */
355
- export type FoirVisitorSource = string | (() => string | null | undefined | Promise<string | null | undefined>);
375
+ export type FoirVisitorSource = string | FoirVisitor | (() => string | FoirVisitor | null | undefined | Promise<string | FoirVisitor | null | undefined>);
376
+ /** Where a stated visitor is, as a read's `contexts` carries it. */
377
+ export interface FoirVisitorPlace {
378
+ country?: string;
379
+ location?: {
380
+ latitude: number;
381
+ longitude: number;
382
+ };
383
+ }
384
+ /** One request's visitor, resolved: the address for the header, and the place for the read. */
385
+ export interface FoirResolvedVisitor {
386
+ address?: string;
387
+ place: FoirVisitorPlace;
388
+ }
389
+ /**
390
+ * Resolve a `visitor` for one request. The provider is called here, once, and
391
+ * what it yields is used for the header and for the read alike, so the two
392
+ * never describe different visitors.
393
+ */
394
+ export declare function resolveFoirVisitor(source: FoirVisitorSource | undefined): Promise<FoirResolvedVisitor>;
356
395
  export declare function buildFoirHeaders(input: FoirHeaderInput): Record<string, string>;
357
396
  /**
358
397
  * Build a `/v1/...` URL from whatever the caller put in `apiUrl`.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The kind a key says it is, by its prefix.
3
+ *
4
+ * A key is one of three kinds, chosen by whoever made it. A publishable key
5
+ * sits in a page or an app. A server key is held by the server that renders an
6
+ * app's pages. An admin key is a machine's own: a sync job, a pipeline, a tool.
7
+ * A key says its kind in its first letters, so this is decided without a
8
+ * request.
9
+ *
10
+ * A key minted before a key had a kind says an older type. A `pk_` key is a
11
+ * publishable key. An `sk_` or a `tc_` key says only that it is not public:
12
+ * the platform holds it as an admin key, and it is reported here as `older`,
13
+ * so that a caller who refuses an admin key by name does not refuse a key
14
+ * whose holder has not yet been able to mint a server key in its place.
15
+ */
16
+ export type FoirKeyKind = 'publishable' | 'server' | 'admin';
17
+ /** What a key's prefix says of it: its kind, or that it is an older secret. */
18
+ export type FoirKeyPrefixKind = FoirKeyKind | 'older';
19
+ /** Where each credential is for, and which key an app holds. */
20
+ export declare const FOIR_CREDENTIALS_DOC = "https://docs.foir.dev/credentials";
21
+ /**
22
+ * What a key says of itself by its prefix, or `undefined` for a string that
23
+ * is no key Fóir made.
24
+ */
25
+ export declare function foirKeyKind(apiKey: string | null | undefined): FoirKeyPrefixKind | undefined;
26
+ /**
27
+ * What an app's client says when it is handed an admin key, naming the client
28
+ * it was handed to. An admin key is a machine's own, so every client an app
29
+ * builds refuses one before any request, in the same words.
30
+ *
31
+ * Only a key that says it is an admin key by its prefix is refused. A key
32
+ * minted before a key had a kind does not say, and is let through.
33
+ */
34
+ export declare function adminKeyRefusal(client: string): string;
35
+ /**
36
+ * Whether a key may be named in a URL. Only a publishable key may: it ships in
37
+ * the page and is served to every visitor, so naming it discloses nothing. Any
38
+ * other key would land in access logs, referrer headers and browser history.
39
+ */
40
+ export declare function isPublishableFoirKey(apiKey: string | null | undefined): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foir/sdk",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Official Foir SDK — editor iframe client + server-side platform helpers",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -1,8 +0,0 @@
1
- /** Stands, below, where a capability or a grant names a model. */
2
- export declare const OLDER_CLAIM_TARGET = "{target}";
3
- /**
4
- * What each capability a token said before it said its grants was worth, as
5
- * the platform reads one. A capability that was worth nothing is worth nothing
6
- * here.
7
- */
8
- export declare const OLDER_CAPABILITY_GRANTS: Readonly<Record<string, readonly string[]>>;