@foir/sdk 0.19.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/dist/app/client.d.ts +109 -21
  3. package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +223 -211
  4. package/dist/app/errors.d.ts +2 -2
  5. package/dist/app/index.d.ts +4 -4
  6. package/dist/app/manifest.d.ts +1 -1
  7. package/dist/app/vocabulary.generated.d.ts +13 -10
  8. package/dist/app-contract.js +27 -31
  9. package/dist/app.js +80 -23
  10. package/dist/auth/access.d.ts +106 -0
  11. package/dist/auth/grant-table.generated.d.ts +13 -0
  12. package/dist/auth/grants.d.ts +38 -0
  13. package/dist/auth/index.d.ts +2 -0
  14. package/dist/auth/operations.d.ts +6 -1
  15. package/dist/auth/ops.d.ts +1 -0
  16. package/dist/auth.js +321 -7
  17. package/dist/client/platform-client.d.ts +1 -1
  18. package/dist/client.js +28 -1
  19. package/dist/codegen/index.js +1 -1
  20. package/dist/data/builtins.d.ts +57 -42
  21. package/dist/data/document.d.ts +14 -0
  22. package/dist/data/index.d.ts +2 -2
  23. package/dist/data/transport.d.ts +27 -20
  24. package/dist/data.js +91 -13
  25. package/dist/index.js +24 -0
  26. package/dist/next.js +47 -5
  27. package/dist/placement/index.d.ts +26 -11
  28. package/dist/placement.js +219 -2
  29. package/dist/push.js +55 -6
  30. package/dist/react/FoirProvider.d.ts +40 -1
  31. package/dist/react/feedback-notes.d.ts +1 -1
  32. package/dist/react/index.d.ts +5 -4
  33. package/dist/react.js +391 -8
  34. package/dist/server/events-client.d.ts +7 -7
  35. package/dist/server/index.d.ts +1 -1
  36. package/dist/server/require-scoped-token.d.ts +1 -1
  37. package/dist/server/server-client.d.ts +1 -1
  38. package/dist/server/verify-scoped-token.d.ts +18 -23
  39. package/dist/server.js +228 -14
  40. package/dist/transport/foir-http.d.ts +44 -5
  41. package/dist/transport/key-kind.d.ts +40 -0
  42. package/package.json +6 -5
@@ -44,6 +44,7 @@ export declare const AUTH_OPS: {
44
44
  readonly customerSignupContext: "customerSignupContext";
45
45
  readonly currentUser: "currentUser";
46
46
  readonly myProviderToken: "myProviderToken";
47
+ readonly whoami: "whoami";
47
48
  readonly customerLogin: "customerLogin";
48
49
  readonly customerRegister: "customerRegister";
49
50
  readonly customerRequestOTP: "customerRequestOTP";
package/dist/auth.js CHANGED
@@ -1,6 +1,30 @@
1
1
  // src/graphql/foir-request.ts
2
2
  import { print as print2 } from "graphql";
3
3
 
4
+ // src/transport/key-kind.ts
5
+ var KEY_PREFIXES = [
6
+ ["foir_pub_", "publishable"],
7
+ ["foir_srv_", "server"],
8
+ ["foir_adm_", "admin"],
9
+ ["pk_", "publishable"],
10
+ ["sk_", "older"],
11
+ ["tc_", "older"]
12
+ ];
13
+ var FOIR_CREDENTIALS_DOC = "https://docs.foir.dev/credentials";
14
+ function foirKeyKind(apiKey) {
15
+ if (typeof apiKey !== "string") return void 0;
16
+ for (const [prefix, kind] of KEY_PREFIXES) {
17
+ if (apiKey.startsWith(prefix)) return kind;
18
+ }
19
+ return void 0;
20
+ }
21
+ function adminKeyRefusal(client) {
22
+ return `[foir] ${client} was given an admin key. An admin key is a machine's own: nobody signs in through it, and an app never holds one. Build this client from your publishable key, or from your server key on the server that renders your pages. A machine that acts for itself uses createServerClient from @foir/sdk/server. See ${FOIR_CREDENTIALS_DOC}`;
23
+ }
24
+ function isPublishableFoirKey(apiKey) {
25
+ return foirKeyKind(apiKey) === "publishable";
26
+ }
27
+
4
28
  // src/graphql/cdn-route.ts
5
29
  import { print } from "graphql";
6
30
 
@@ -125,6 +149,28 @@ function refuseCredentialHeaders(extra) {
125
149
  }
126
150
  }
127
151
  var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
152
+ function sentCoordinate(value) {
153
+ return Math.round(value * 100) / 100;
154
+ }
155
+ async function resolveFoirVisitor(source) {
156
+ const value = typeof source === "function" ? await source() : source;
157
+ if (value === null || value === void 0) return { place: {} };
158
+ if (typeof value === "string") {
159
+ const address2 = value.trim();
160
+ return { address: address2 || void 0, place: {} };
161
+ }
162
+ const address = value.address?.trim();
163
+ const country = value.country?.trim();
164
+ const place = {};
165
+ if (country) place.country = country;
166
+ if (Number.isFinite(value.latitude) && Number.isFinite(value.longitude)) {
167
+ place.location = {
168
+ latitude: sentCoordinate(value.latitude),
169
+ longitude: sentCoordinate(value.longitude)
170
+ };
171
+ }
172
+ return { address: address || void 0, place };
173
+ }
128
174
  function buildFoirHeaders(input) {
129
175
  const headers = {
130
176
  "Content-Type": "application/json"
@@ -277,7 +323,7 @@ function isCdnEligible(env, documentText, bearerToken, orgId) {
277
323
  if (orgId) return false;
278
324
  if (isFoirPreview(env)) return false;
279
325
  const apiKey = resolveFoirApiKey(env);
280
- if (!apiKey?.startsWith("pk_")) return false;
326
+ if (!isPublishableFoirKey(apiKey)) return false;
281
327
  return !/\bmutation\b/.test(documentText);
282
328
  }
283
329
 
@@ -290,6 +336,9 @@ async function foirRequest(env, document, variables, options) {
290
336
  "[foir] PLATFORM_API_URL or PLATFORM_API_KEY is not set \u2014 add them to .env"
291
337
  );
292
338
  }
339
+ if (foirKeyKind(apiKey) === "admin") {
340
+ throw new FoirRequestError(adminKeyRefusal("foirRequest"));
341
+ }
293
342
  const headers = buildFoirHeaders({
294
343
  apiKey,
295
344
  draftChannel: isFoirPreview(env),
@@ -679,7 +728,7 @@ function toVariableValue(value, label) {
679
728
  return value.toISOString();
680
729
  }
681
730
  if (Array.isArray(value)) {
682
- return value.filter((item) => item !== void 0).map((item, index) => toVariableValue(item, `${label}[${index}]`));
731
+ return value.filter((item) => item !== void 0).map((item, index2) => toVariableValue(item, `${label}[${index2}]`));
683
732
  }
684
733
  if (kind === "object") {
685
734
  const out = {};
@@ -852,10 +901,7 @@ function foirDataEndpoint(apiUrl, path) {
852
901
  return foirApiEndpoint(apiUrl, path);
853
902
  }
854
903
  async function resolveVisitorAddress(config) {
855
- const source = config.visitor;
856
- const value = typeof source === "function" ? await source() : source;
857
- const address = value?.trim();
858
- return address ? address : void 0;
904
+ return (await resolveFoirVisitor(config.visitor)).address;
859
905
  }
860
906
  async function resolveBearerToken(config) {
861
907
  const source = config.bearerToken;
@@ -896,7 +942,7 @@ async function resolveFoirAuth(config) {
896
942
  };
897
943
  }
898
944
  function foirRoute(input) {
899
- if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
945
+ if (!isPublishableFoirKey(input.apiKey)) return { lane: "origin", reason: "key" };
900
946
  if (input.bearerToken) return { lane: "origin", reason: "bearer" };
901
947
  if (input.orgId || input.orgUrl) return { lane: "origin", reason: "org" };
902
948
  if (input.mutation) return { lane: "origin", reason: "operation" };
@@ -1034,6 +1080,9 @@ var AUTH_OPS = {
1034
1080
  // Post-credential self reads.
1035
1081
  currentUser: "currentUser",
1036
1082
  myProviderToken: "myProviderToken",
1083
+ // What the calling credential may do, and for a signed-in customer the roles
1084
+ // they hold. `./access` reads it.
1085
+ whoami: "whoami",
1037
1086
  // Methods.
1038
1087
  customerLogin: "customerLogin",
1039
1088
  customerRegister: "customerRegister",
@@ -1357,6 +1406,264 @@ async function requireAdmin(adapter, options) {
1357
1406
  return user;
1358
1407
  }
1359
1408
 
1409
+ // src/auth/grant-table.generated.ts
1410
+ var GRANT_TABLE = [
1411
+ ["records:read", true, ["own", "all"], []],
1412
+ ["records:create", true, [], []],
1413
+ ["records:write", true, ["own", "all"], []],
1414
+ ["records:delete", true, ["own", "all"], []],
1415
+ ["records:publish", true, ["own", "all"], []],
1416
+ ["records:attribute", true, [], []],
1417
+ ["search:read", true, [], []],
1418
+ ["embeddings:read", false, [], []],
1419
+ ["embeddings:write", false, [], []],
1420
+ ["schemas:read", false, [], []],
1421
+ ["drafts:read", false, [], []],
1422
+ ["files:read", false, ["own", "all"], []],
1423
+ ["files:write", false, ["own", "all"], []],
1424
+ ["files:delete", false, ["own", "all"], []],
1425
+ ["notifications:read", false, ["own", "all"], []],
1426
+ ["notifications:write", false, ["own", "named", "all"], []],
1427
+ ["notifications:consent", false, [], []],
1428
+ ["pushcredentials:read", false, [], []],
1429
+ ["pushcredentials:write", false, [], []],
1430
+ ["notes:read", false, ["own", "all"], []],
1431
+ ["notes:write", false, ["own", "all"], []],
1432
+ ["notes:create", false, [], []],
1433
+ ["approvals:read", false, [], []],
1434
+ ["approvals:write", false, [], []],
1435
+ ["rooms:read", true, [], []],
1436
+ ["rooms:write", true, [], []],
1437
+ ["presence:read", true, [], []],
1438
+ ["operations:read", false, [], []],
1439
+ ["operations:execute", true, [], []],
1440
+ ["operations:write", false, [], []],
1441
+ ["operations:complete", false, [], []],
1442
+ ["status:write", false, [], []],
1443
+ ["appconfig:read", false, [], []],
1444
+ ["schedules:read", false, [], []],
1445
+ ["schedules:write", false, [], []],
1446
+ ["hooks:read", false, [], []],
1447
+ ["hooks:write", false, [], []],
1448
+ ["relyingparties:read", false, [], []],
1449
+ ["relyingparties:write", false, [], []],
1450
+ ["domains:read", false, [], []],
1451
+ ["domains:write", false, [], []],
1452
+ ["secrets:read", false, [], []],
1453
+ ["secrets:list", false, [], []],
1454
+ ["secrets:put", false, [], []],
1455
+ ["secrets:delete", false, [], []],
1456
+ ["appsecrets:read", false, [], []],
1457
+ ["appsecrets:list", false, [], []],
1458
+ ["appsecrets:put", false, [], []],
1459
+ ["appsecrets:delete", false, [], []],
1460
+ ["opsecrets:read", false, [], []],
1461
+ ["opsecrets:write", false, [], []],
1462
+ ["settings:read", false, [], []],
1463
+ ["settings:write", false, [], []],
1464
+ ["designtokens:read", false, [], []],
1465
+ ["designtokens:write", false, [], []],
1466
+ ["models:read", false, [], []],
1467
+ ["models:write", false, [], []],
1468
+ ["configs:read", false, [], []],
1469
+ ["configs:write", false, [], []],
1470
+ ["apps:read", false, [], []],
1471
+ ["apps:write", false, [], []],
1472
+ ["events:read", false, [], []],
1473
+ ["targeting:debug", false, [], []],
1474
+ ["visitors:forward", false, [], []],
1475
+ ["apikeys:read", false, [], []],
1476
+ ["apikeys:write", false, [], []],
1477
+ ["organizations:read", false, [], []],
1478
+ ["organizations:write", false, [], []],
1479
+ ["organizations:create", false, [], []],
1480
+ ["organizations:delete", false, [], []],
1481
+ ["organizations:reach", false, [], []],
1482
+ ["members:read", false, [], []],
1483
+ ["members:write", false, [], []],
1484
+ ["roles:read", false, [], []],
1485
+ ["roles:write", false, [], []],
1486
+ ["projects:read", false, [], []],
1487
+ ["projects:write", false, [], []],
1488
+ ["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"]],
1489
+ ["projects:create", false, [], []],
1490
+ ["projects:delete", false, [], []],
1491
+ ["tenants:read", false, [], []],
1492
+ ["tenants:write", false, [], []],
1493
+ ["tenants:delete", false, [], []],
1494
+ ["audit:read", false, [], []],
1495
+ ["export:run", false, [], []],
1496
+ ["billing:read", false, [], []],
1497
+ ["billing:manage", false, [], []],
1498
+ ["usage:read", false, [], []],
1499
+ ["controlkeys:read", false, [], []],
1500
+ ["controlkeys:write", false, [], []],
1501
+ ["tenanthooks:read", false, [], []],
1502
+ ["tenanthooks:write", false, [], []],
1503
+ ["apps:install", false, [], []],
1504
+ ["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"]],
1505
+ ["platform:read", false, [], []],
1506
+ ["platform:manage", false, [], ["tenant:manage", "platform:read", "platform:create_workspace", "users:setup"]],
1507
+ ["platform:create_workspace", false, [], []],
1508
+ ["users:read", false, ["own", "named", "all"], []],
1509
+ ["users:write", false, ["own", "all"], []],
1510
+ ["users:setup", false, [], []],
1511
+ ["users:delete", false, ["own", "all"], []],
1512
+ ["users:create", false, [], []],
1513
+ ["identities:read", false, [], []],
1514
+ ["identities:delete", false, [], []],
1515
+ ["usersecrets:read", false, [], []],
1516
+ ["usersecrets:list", false, [], []],
1517
+ ["usersecrets:put", false, [], []],
1518
+ ["usersecrets:delete", false, [], []],
1519
+ ["auth:login_with_foir", false, [], []],
1520
+ ["signinproviders:read", false, [], []],
1521
+ ["signinproviders:write", false, [], []]
1522
+ ];
1523
+
1524
+ // src/auth/grants.ts
1525
+ var WILDCARD = "*";
1526
+ var RESOURCE_WILDCARD = ":*";
1527
+ var index;
1528
+ function catalogue() {
1529
+ if (!index) {
1530
+ const byName = new Map(GRANT_TABLE.map((row) => [row[0], row]));
1531
+ const longestFirst = [...byName.keys()].sort(
1532
+ (a, b) => a.length !== b.length ? b.length - a.length : a < b ? -1 : 1
1533
+ );
1534
+ index = { byName, longestFirst };
1535
+ }
1536
+ return index;
1537
+ }
1538
+ var reachRank = { own: 1, named: 2, all: 3 };
1539
+ function hasReach(permission, reach) {
1540
+ const reaches = permission[2];
1541
+ return reaches.length === 0 ? reach === "own" : reaches.includes(reach);
1542
+ }
1543
+ function resourceOf(name) {
1544
+ const i = name.indexOf(":");
1545
+ return i < 0 ? name : name.slice(0, i);
1546
+ }
1547
+ function parseGrant(s) {
1548
+ const { byName, longestFirst } = catalogue();
1549
+ for (const name of longestFirst) {
1550
+ if (!s.startsWith(name)) continue;
1551
+ let rest = s.slice(name.length);
1552
+ if (rest !== "" && rest[0] !== "." && rest[0] !== ":") continue;
1553
+ const permission = byName.get(name);
1554
+ let reach = "own";
1555
+ if (rest.startsWith(".")) {
1556
+ const after = rest.slice(1);
1557
+ const colon = after.indexOf(":");
1558
+ const word = colon < 0 ? after : after.slice(0, colon);
1559
+ if (word !== "named" && word !== "all") return null;
1560
+ if (!hasReach(permission, word)) return null;
1561
+ reach = word;
1562
+ rest = after.slice(word.length);
1563
+ }
1564
+ let target = "";
1565
+ if (rest.startsWith(":")) {
1566
+ target = rest.slice(1);
1567
+ if (!permission[1]) return null;
1568
+ if (target === "" || /[: \t\n*]/.test(target)) return null;
1569
+ }
1570
+ return { permission, reach, target };
1571
+ }
1572
+ return null;
1573
+ }
1574
+ function covers(held, required) {
1575
+ return held.permission === required.permission && reachRank[held.reach] >= reachRank[required.reach] && (held.target === "" || held.target === required.target);
1576
+ }
1577
+ function resourceWildcard(held) {
1578
+ if (!held.endsWith(RESOURCE_WILDCARD)) return null;
1579
+ const resource = held.slice(0, -RESOURCE_WILDCARD.length);
1580
+ return resource.includes(":") ? null : resource;
1581
+ }
1582
+ function holdsAt(held, required, depth) {
1583
+ for (const h of held) {
1584
+ if (h === WILDCARD) return true;
1585
+ const resource = resourceWildcard(h);
1586
+ if (resource !== null) {
1587
+ if (resource === resourceOf(required.permission[0])) return true;
1588
+ continue;
1589
+ }
1590
+ const grant = parseGrant(h);
1591
+ if (grant === null) continue;
1592
+ if (covers(grant, required)) return true;
1593
+ const implies = grant.permission[3];
1594
+ if (depth < 4 && implies.length > 0 && holdsAt(implies, required, depth + 1)) return true;
1595
+ }
1596
+ return false;
1597
+ }
1598
+ function holds(held, required) {
1599
+ const grant = parseGrant(required);
1600
+ return grant !== null && holdsAt(held, grant, 0);
1601
+ }
1602
+
1603
+ // src/auth/access.ts
1604
+ function accessFrom(who) {
1605
+ const customer = who?.credential?.customer;
1606
+ if (!customer?.id) return null;
1607
+ return {
1608
+ customerId: customer.id,
1609
+ roles: Array.isArray(customer.roles) ? [...customer.roles] : null,
1610
+ scopes: [...who?.authority?.effective ?? []]
1611
+ };
1612
+ }
1613
+ async function customerAccess(env, options) {
1614
+ return accessFrom(await authOp(env, AUTH_OPS.whoami, void 0, options));
1615
+ }
1616
+ async function getSessionAccess(adapter, env) {
1617
+ if (!await getSessionToken(adapter)) return null;
1618
+ return accessFrom(await authedOp(adapter, env, AUTH_OPS.whoami));
1619
+ }
1620
+ function hasRole(access, key) {
1621
+ return access?.roles?.includes(key) ?? false;
1622
+ }
1623
+ function hasScope(access, scope) {
1624
+ return access ? holds(access.scopes, scope) : false;
1625
+ }
1626
+ var FoirAccessDeniedError = class extends Error {
1627
+ code = "FOIR_ACCESS_DENIED";
1628
+ reason;
1629
+ /** The role that was required, when one was. */
1630
+ role;
1631
+ /** The scope that was required, when one was. */
1632
+ scope;
1633
+ constructor(reason, required) {
1634
+ super(denialMessage(reason, required));
1635
+ this.name = "FoirAccessDeniedError";
1636
+ this.reason = reason;
1637
+ this.role = required.role;
1638
+ this.scope = required.scope;
1639
+ }
1640
+ };
1641
+ function denialMessage(reason, required) {
1642
+ switch (reason) {
1643
+ case "signed_out":
1644
+ return "[foir] nobody is signed in";
1645
+ case "role_not_held":
1646
+ return `[foir] the signed-in customer does not hold the role "${required.role}"`;
1647
+ case "roles_unknown":
1648
+ return `[foir] the platform did not say which roles the signed-in customer holds, so "${required.role}" could not be checked`;
1649
+ case "scope_not_held":
1650
+ return `[foir] the signed-in customer's scopes do not cover "${required.scope}"`;
1651
+ }
1652
+ }
1653
+ async function requireRole(adapter, env, key) {
1654
+ const access = await getSessionAccess(adapter, env);
1655
+ if (!access) throw new FoirAccessDeniedError("signed_out", { role: key });
1656
+ if (access.roles === null) throw new FoirAccessDeniedError("roles_unknown", { role: key });
1657
+ if (!access.roles.includes(key)) throw new FoirAccessDeniedError("role_not_held", { role: key });
1658
+ return access;
1659
+ }
1660
+ async function requireScope(adapter, env, scope) {
1661
+ const access = await getSessionAccess(adapter, env);
1662
+ if (!access) throw new FoirAccessDeniedError("signed_out", { scope });
1663
+ if (!holds(access.scopes, scope)) throw new FoirAccessDeniedError("scope_not_held", { scope });
1664
+ return access;
1665
+ }
1666
+
1360
1667
  // src/auth/hosted.ts
1361
1668
  var DEFAULT_COOKIE_NAME = "foir_customer_session";
1362
1669
  var DEFAULT_SESSION_PATH = "/session/token";
@@ -1567,6 +1874,7 @@ function trimSlash(s) {
1567
1874
  }
1568
1875
  export {
1569
1876
  AUTH_OPS,
1877
+ FoirAccessDeniedError,
1570
1878
  FoirAuthDowngradedError,
1571
1879
  FoirSessionExpiredError,
1572
1880
  authedOp,
@@ -1574,6 +1882,7 @@ export {
1574
1882
  clearSession,
1575
1883
  createFoirSessionClient,
1576
1884
  currentUser,
1885
+ customerAccess,
1577
1886
  customerJoinWaitlist,
1578
1887
  customerLogin,
1579
1888
  customerLoginOTP,
@@ -1594,8 +1903,11 @@ export {
1594
1903
  getAuthConfig,
1595
1904
  getAuthProviders,
1596
1905
  getDefaultAuthProvider,
1906
+ getSessionAccess,
1597
1907
  getSessionCustomer,
1598
1908
  getSessionToken,
1909
+ hasRole,
1910
+ hasScope,
1599
1911
  hostedLoginUrl,
1600
1912
  hostedLogoutUrl,
1601
1913
  loginWithOTP,
@@ -1608,6 +1920,8 @@ export {
1608
1920
  registerWithPassword,
1609
1921
  requireAdmin,
1610
1922
  requireAuth,
1923
+ requireRole,
1924
+ requireScope,
1611
1925
  resetPasswordAndSignIn,
1612
1926
  saveSession,
1613
1927
  selectOrganization,
@@ -17,7 +17,7 @@ export declare class PlatformClientError extends Error {
17
17
  * ```ts
18
18
  * const client = createPlatformClient({
19
19
  * apiUrl: 'https://api.example.com/graphql',
20
- * apiKey: 'sk_test_...',
20
+ * apiKey: 'foir_pub_...', // runs in the browser: a publishable key only
21
21
  * });
22
22
  *
23
23
  * const { data } = await client.query(
package/dist/client.js CHANGED
@@ -1,6 +1,30 @@
1
1
  // src/graphql/foir-request.ts
2
2
  import { print as print2 } from "graphql";
3
3
 
4
+ // src/transport/key-kind.ts
5
+ var KEY_PREFIXES = [
6
+ ["foir_pub_", "publishable"],
7
+ ["foir_srv_", "server"],
8
+ ["foir_adm_", "admin"],
9
+ ["pk_", "publishable"],
10
+ ["sk_", "older"],
11
+ ["tc_", "older"]
12
+ ];
13
+ var FOIR_CREDENTIALS_DOC = "https://docs.foir.dev/credentials";
14
+ function foirKeyKind(apiKey) {
15
+ if (typeof apiKey !== "string") return void 0;
16
+ for (const [prefix, kind] of KEY_PREFIXES) {
17
+ if (apiKey.startsWith(prefix)) return kind;
18
+ }
19
+ return void 0;
20
+ }
21
+ function adminKeyRefusal(client) {
22
+ return `[foir] ${client} was given an admin key. An admin key is a machine's own: nobody signs in through it, and an app never holds one. Build this client from your publishable key, or from your server key on the server that renders your pages. A machine that acts for itself uses createServerClient from @foir/sdk/server. See ${FOIR_CREDENTIALS_DOC}`;
23
+ }
24
+ function isPublishableFoirKey(apiKey) {
25
+ return foirKeyKind(apiKey) === "publishable";
26
+ }
27
+
4
28
  // src/graphql/cdn-route.ts
5
29
  import { print } from "graphql";
6
30
 
@@ -282,7 +306,7 @@ function isCdnEligible(env, documentText, bearerToken, orgId) {
282
306
  if (orgId) return false;
283
307
  if (isFoirPreview(env)) return false;
284
308
  const apiKey = resolveFoirApiKey(env);
285
- if (!apiKey?.startsWith("pk_")) return false;
309
+ if (!isPublishableFoirKey(apiKey)) return false;
286
310
  return !/\bmutation\b/.test(documentText);
287
311
  }
288
312
 
@@ -329,6 +353,9 @@ async function foirRequest(env, document, variables, options) {
329
353
  "[foir] PLATFORM_API_URL or PLATFORM_API_KEY is not set \u2014 add them to .env"
330
354
  );
331
355
  }
356
+ if (foirKeyKind(apiKey) === "admin") {
357
+ throw new FoirRequestError(adminKeyRefusal("foirRequest"));
358
+ }
332
359
  const headers = buildFoirHeaders({
333
360
  apiKey,
334
361
  draftChannel: isFoirPreview(env),
@@ -15,7 +15,7 @@ function schemaEntry(apiUrl, key) {
15
15
  headers: {
16
16
  "x-api-key": key ?? "",
17
17
  // Read the draft channel so freshly pushed changes are visible
18
- // (silently ignored by public keys, which can't see drafts).
18
+ // (ignored by an app's key that does not hold drafts:read).
19
19
  "X-Foir-Schema-Channel": "draft"
20
20
  }
21
21
  }
@@ -520,7 +520,7 @@ export interface FoirCustomerNote {
520
520
  * What a note surface has to know about the project before it renders anything
521
521
  * — `foir.ns.notes.noteConfig`.
522
522
  *
523
- * Charged `notes:write` rather than `notes:read`, and refused to a caller with
523
+ * Charged `notes:create` rather than `notes:read`, and refused to a caller with
524
524
  * no customer session, because the only caller it is useful to is one that can
525
525
  * submit. So a composer can read it as its single readiness probe: an answer
526
526
  * means this visitor may write, and a refusal names which half is missing.
@@ -551,18 +551,31 @@ export interface FoirMyNotesArgs {
551
551
  * internal ones.
552
552
  */
553
553
  export type FoirMyNotesResult = FoirConnection<FoirCustomerNote>;
554
- /** `createMyNote` arguments. */
555
- export interface FoirCreateMyNoteArgs {
554
+ /**
555
+ * `writeNote` arguments: the one note create, for a signed-in customer and for
556
+ * a visitor alike. It asks `notes:create` of both.
557
+ *
558
+ * A signed-in customer's note is theirs and comes back in `myNotes`. A
559
+ * visitor's, through a key that gives `notes:create` to whoever comes through
560
+ * it, on a project that takes visitors' notes, is nobody's: ask for its `id`
561
+ * and `createdAt` and nothing else, because nothing reads it back.
562
+ *
563
+ * Served by a data API that has the field. `<FoirFeedback>` still writes
564
+ * through `createMyNote`, the same create under its older name, which every
565
+ * data API serves.
566
+ */
567
+ export interface FoirWriteNoteArgs {
568
+ /** A visitor may write about a record and nothing else. */
556
569
  subjectType: FoirNoteSubjectType;
557
570
  subjectId: string;
558
571
  /** Required when `subjectType` is `'record'`, refused otherwise. */
559
572
  subjectModelKey?: string;
560
573
  body: string;
561
- /** Reply within an existing thread. */
574
+ /** Reply within an existing thread. Refused where nobody is signed in. */
562
575
  parentNoteId?: string;
563
576
  /**
564
577
  * Files to submit with this note, already uploaded through
565
- * `createFileUpload` / `confirmFileUpload`.
578
+ * `createFileUpload` / `confirmFileUpload`. Refused where nobody is signed in.
566
579
  *
567
580
  * A file this caller cannot read is REFUSED and the note is not written. That
568
581
  * is not a rough edge to code around: a create that accepted the note and
@@ -570,53 +583,40 @@ export interface FoirCreateMyNoteArgs {
570
583
  * screenshot nobody has.
571
584
  */
572
585
  attachmentFileIds?: string[];
573
- }
574
- /**
575
- * `submitAnonymousNote` arguments — feedback from a visitor with no account.
576
- *
577
- * Present on the project's schema only while the project has opted in
578
- * (Notes settings → "Accept anonymous notes"). The feedback widget does NOT
579
- * use this: a signed-out visitor there is still asked to sign in, so this
580
- * mutation is the only path a visitor's note can take.
581
- *
582
- * WRITE-ONLY. Nothing reads an anonymous note back on the public plane, which
583
- * is why the result is a receipt rather than the note.
584
- */
585
- export interface FoirSubmitAnonymousNoteArgs {
586
- /**
587
- * Must be `'record'` — the only thing a visitor may write a note about.
588
- * Narrower than `FoirCreateMyNoteArgs`, where a customer may also address a
589
- * file or a model.
590
- */
591
- subjectType: 'record';
592
- subjectId: string;
593
586
  /**
594
- * The record's model key. REQUIRED here, unlike on `createMyNote` where it is
595
- * conditional: the subject is always a record on this lane, so the condition
596
- * is always met.
597
- */
598
- subjectModelKey: string;
599
- body: string;
600
- /**
601
- * Optional. Where the operators may reply.
587
+ * Where the operators may reply to a visitor. Refused from a signed-in
588
+ * customer, whose note has an author.
602
589
  *
603
- * Captured as typed and NEVER verified — it is not treated as proof of who
590
+ * Captured as typed and NEVER verified: it is not treated as proof of who
604
591
  * wrote the note, and nothing is sent to it to confirm it. Do not build a
605
592
  * flow that assumes the address is reachable.
606
593
  */
607
594
  contactEmail?: string;
608
595
  }
609
- /** What `submitAnonymousNote` returns. */
610
- export interface FoirAnonymousNoteReceipt {
596
+ /**
597
+ * `createMyNote` arguments.
598
+ *
599
+ * @deprecated `createMyNote` is the older name of `writeNote` for a signed-in
600
+ * customer. Use `writeNote` and {@link FoirWriteNoteArgs}.
601
+ */
602
+ export interface FoirCreateMyNoteArgs {
603
+ subjectType: FoirNoteSubjectType;
604
+ subjectId: string;
605
+ /** Required when `subjectType` is `'record'`, refused otherwise. */
606
+ subjectModelKey?: string;
607
+ body: string;
608
+ /** Reply within an existing thread. */
609
+ parentNoteId?: string;
611
610
  /**
612
- * A reference for this submission, to quote when following it up.
611
+ * Files to submit with this note, already uploaded through
612
+ * `createFileUpload` / `confirmFileUpload`.
613
613
  *
614
- * NOT a handle: there is no read that takes it. Showing it to the visitor is
615
- * the whole of its purpose.
614
+ * A file this caller cannot read is REFUSED and the note is not written. That
615
+ * is not a rough edge to code around: a create that accepted the note and
616
+ * dropped the attachment would leave the author believing they sent a
617
+ * screenshot nobody has.
616
618
  */
617
- reference: string;
618
- /** When the platform recorded the submission. */
619
- submittedAt: string;
619
+ attachmentFileIds?: string[];
620
620
  }
621
621
  export type FoirDeviceType = 'DESKTOP' | 'MOBILE' | 'TABLET';
622
622
  export type FoirPlatformType = 'IOS' | 'ANDROID' | 'WEB';
@@ -638,4 +638,19 @@ export interface FoirResolveContext {
638
638
  locale?: string | null;
639
639
  platform?: FoirPlatformType | null;
640
640
  preview?: boolean | null;
641
+ /**
642
+ * The country of the visitor a server is calling for, as an ISO 3166-1
643
+ * alpha-2 code. Only a key holding `visitors:forward` may say it, and a
644
+ * client given a `visitor` attaches it to every read, so there is rarely a
645
+ * reason to write it on one.
646
+ */
647
+ country?: string | null;
648
+ /**
649
+ * Where the visitor a server is calling for is, in decimal degrees. Only a
650
+ * key holding `visitors:forward` may say it.
651
+ */
652
+ location?: {
653
+ latitude: number;
654
+ longitude: number;
655
+ } | null;
641
656
  }
@@ -51,6 +51,7 @@
51
51
  * inferring a literal would reinstate the defect above under a condition no
52
52
  * caller can see.
53
53
  */
54
+ import { type FoirVisitorPlace } from '../transport/foir-http';
54
55
  import type { FoirManifestOp, FoirManifestOpPayload, FoirManifestRelation, FoirManifestSelection, FoirManifestSelectionField, FoirManifestSelectionUnion } from './manifest';
55
56
  /**
56
57
  * One model, as the printer needs it.
@@ -260,6 +261,19 @@ export declare function planFoirModelCall(source: FoirSelectionSource, call: Foi
260
261
  * choose, since only the caller knows which calls are the same one.
261
262
  */
262
263
  export declare function withCreateIdempotencyKey(source: FoirSelectionSource, model: string, op: string, args: Record<string, unknown>): Record<string, unknown>;
264
+ /**
265
+ * A read's arguments with where its stated visitor is, when the op takes
266
+ * `contexts` and the client was given a visitor with a place.
267
+ *
268
+ * It is attached here, to every read, and not left to each call: a page
269
+ * renders with many reads, and one that forgot would be answered as a visitor
270
+ * nobody could place while the read beside it was not.
271
+ *
272
+ * What a call says of the place itself wins, part by part: a caller that
273
+ * passes `contexts.country` for one read has said something more exact than
274
+ * the client's visitor.
275
+ */
276
+ export declare function withStatedVisitor(op: FoirManifestOp | undefined, args: Record<string, unknown>, place: FoirVisitorPlace): Record<string, unknown>;
263
277
  /**
264
278
  * Turn a namespace call into a document.
265
279
  *