@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/CHANGELOG.md CHANGED
@@ -131,14 +131,65 @@ Changes on `dev` that have not been published to npm.
131
131
  release that carries `writeNote`. `foir.ns.notes` answers any field the
132
132
  platform serves, so the call itself needs no new SDK.
133
133
 
134
+ - **A server says where its visitor is** (`@foir/sdk/data`). `visitor` takes
135
+ an object as well as an address: `{ address, country, latitude, longitude }`,
136
+ or a function that yields one for each request. The address is sent as
137
+ `X-Foir-Visitor-IP`, as before. The country and the coordinates are attached
138
+ to every read, in its `contexts`, so location targeting is decided by where
139
+ the visitor is and not by where your server is. Coordinates are sent at two
140
+ decimal places, and latitude and longitude go together or not at all. The
141
+ visitor is resolved once for a request, so the header and the read name the
142
+ same one. A string is still the address alone. New type: `FoirVisitor`, and
143
+ `country` and `location` on `FoirResolveContext`. It needs a key holding
144
+ `visitors:forward`, and the platform release that takes `contexts.country`
145
+ and `contexts.location`: an earlier platform refuses a read that names them.
146
+
134
147
  ### Changed
135
148
 
149
+ - **Breaking: an app client refuses an admin key** (`@foir/sdk/data`).
150
+ `createFoirClient` given a key that begins `foir_adm_` throws before any
151
+ request. An admin key is a machine's own: nobody signs in through it, and an
152
+ app never holds one. Build the client from a publishable key, or from a
153
+ server key on the server that renders your pages. A machine that acts for
154
+ itself uses `createServerClient` from `@foir/sdk/server`. A key minted
155
+ before a key had a kind (`sk_`) is not refused here.
156
+ - **A key's kind is read from its prefix in one place.** A key is publishable
157
+ (`foir_pub_`), server (`foir_srv_`) or admin (`foir_adm_`), and a key minted
158
+ earlier is `pk_`, `sk_` or `tc_`. Only a publishable key, in either format,
159
+ is named in a URL and takes the shared cache lane. Earlier versions looked
160
+ for the letters `pk_` alone, so a `foir_pub_` key was sent to the origin and
161
+ missed the shared cache.
162
+ - **`visitor` takes a server key** (`@foir/sdk/data`). A client that names the
163
+ visitor it calls for is built from a server key (`foir_srv_`), or from a
164
+ secret key minted before a key had a kind. Earlier versions looked for the
165
+ letters `sk_` alone and refused a server key. A publishable key is still
166
+ refused `visitor`, in either format.
167
+ - **The events client takes an admin key** (`@foir/sdk/server`).
168
+ `createEventsClient` refuses a publishable key and a server key by name, and
169
+ takes an admin key (`foir_adm_`) or a secret key minted earlier.
136
170
  - **`FOIR_EVENTS_SCOPE` is `events:read`** (`@foir/sdk/server`). It was
137
171
  `events:list.project`, the name the event log's permission had when a key
138
172
  was written with scopes. A key is made with the permission catalogue's
139
173
  names, where it is `events:read`, and that is what a refusal names. A key
140
174
  minted under the older name holds `events:read` and needs no change.
141
175
 
176
+ ### Removed
177
+
178
+ - **Breaking: `ScopedTokenClaims` has no `cap`, `rrm` or `rwm`**
179
+ (`@foir/sdk/server`). The platform no longer writes the claims a token
180
+ carried before it said its grants. Read `grants`, or `recordsReadModels`,
181
+ `recordsWriteModels` and `recordsCreateModels`.
182
+ - **Breaking: `PlacementGrant` has no `grantedScopes` or `withheld`**
183
+ (`@foir/sdk/placement`). A console sends what a page's token carries as
184
+ `grants` and what it was refused as `withheldGrants`, and no longer sends
185
+ the same two lists under their older names. Read `session.grant.grants`, or
186
+ ask `session.can`.
187
+ - **Breaking: `verifyScopedToken` refuses a token that does not say its
188
+ grants** (`@foir/sdk/server`), with `the token says no grants`, as the
189
+ platform refuses it. It used to read such a token as what its older claims
190
+ were worth. `requireScopedToken` answers it 401 like any token that does not
191
+ verify.
192
+
142
193
  ### Deprecated
143
194
 
144
195
  - **`FoirCreateMyNoteArgs`, `FoirSubmitAnonymousNoteArgs` and
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),
@@ -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" };
@@ -1362,10 +1408,10 @@ async function requireAdmin(adapter, options) {
1362
1408
 
1363
1409
  // src/auth/grant-table.generated.ts
1364
1410
  var GRANT_TABLE = [
1365
- ["records:read", true, ["own", "named", "all"], []],
1411
+ ["records:read", true, ["own", "all"], []],
1366
1412
  ["records:create", true, [], []],
1367
- ["records:write", true, ["own", "named", "all"], []],
1368
- ["records:delete", true, ["own", "named", "all"], []],
1413
+ ["records:write", true, ["own", "all"], []],
1414
+ ["records:delete", true, ["own", "all"], []],
1369
1415
  ["records:publish", true, ["own", "all"], []],
1370
1416
  ["records:attribute", true, [], []],
1371
1417
  ["search:read", true, [], []],
@@ -1381,8 +1427,8 @@ var GRANT_TABLE = [
1381
1427
  ["notifications:consent", false, [], []],
1382
1428
  ["pushcredentials:read", false, [], []],
1383
1429
  ["pushcredentials:write", false, [], []],
1384
- ["notes:read", false, [], []],
1385
- ["notes:write", false, [], []],
1430
+ ["notes:read", false, ["own", "all"], []],
1431
+ ["notes:write", false, ["own", "all"], []],
1386
1432
  ["notes:create", false, [], []],
1387
1433
  ["approvals:read", false, [], []],
1388
1434
  ["approvals:write", false, [], []],
@@ -1439,7 +1485,7 @@ var GRANT_TABLE = [
1439
1485
  ["roles:write", false, [], []],
1440
1486
  ["projects:read", false, [], []],
1441
1487
  ["projects:write", false, [], []],
1442
- ["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"]],
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"]],
1443
1489
  ["projects:create", false, [], []],
1444
1490
  ["projects:delete", false, [], []],
1445
1491
  ["tenants:read", false, [], []],
@@ -1455,7 +1501,7 @@ var GRANT_TABLE = [
1455
1501
  ["tenanthooks:read", false, [], []],
1456
1502
  ["tenanthooks:write", false, [], []],
1457
1503
  ["apps:install", false, [], []],
1458
- ["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"]],
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"]],
1459
1505
  ["platform:read", false, [], []],
1460
1506
  ["platform:manage", false, [], ["tenant:manage", "platform:read", "platform:create_workspace", "users:setup"]],
1461
1507
  ["platform:create_workspace", false, [], []],
@@ -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
  }
@@ -618,62 +618,6 @@ export interface FoirCreateMyNoteArgs {
618
618
  */
619
619
  attachmentFileIds?: string[];
620
620
  }
621
- /**
622
- * `submitAnonymousNote` arguments — feedback from a visitor with no account.
623
- *
624
- * Present on the project's schema only while the project has opted in
625
- * (Notes settings → "Accept anonymous notes"). The feedback widget does NOT
626
- * use this: a signed-out visitor there is still asked to sign in, so this
627
- * mutation is the only path a visitor's note can take.
628
- *
629
- * WRITE-ONLY. Nothing reads an anonymous note back on the public plane, which
630
- * is why the result is a receipt rather than the note.
631
- *
632
- * @deprecated `submitAnonymousNote` is the older name of `writeNote` for a
633
- * visitor. Use `writeNote` and {@link FoirWriteNoteArgs}, asking for `id` and
634
- * `createdAt`.
635
- */
636
- export interface FoirSubmitAnonymousNoteArgs {
637
- /**
638
- * Must be `'record'` — the only thing a visitor may write a note about.
639
- * Narrower than `FoirCreateMyNoteArgs`, where a customer may also address a
640
- * file or a model.
641
- */
642
- subjectType: 'record';
643
- subjectId: string;
644
- /**
645
- * The record's model key. REQUIRED here, unlike on `createMyNote` where it is
646
- * conditional: the subject is always a record on this lane, so the condition
647
- * is always met.
648
- */
649
- subjectModelKey: string;
650
- body: string;
651
- /**
652
- * Optional. Where the operators may reply.
653
- *
654
- * Captured as typed and NEVER verified — it is not treated as proof of who
655
- * wrote the note, and nothing is sent to it to confirm it. Do not build a
656
- * flow that assumes the address is reachable.
657
- */
658
- contactEmail?: string;
659
- }
660
- /**
661
- * What `submitAnonymousNote` returns.
662
- *
663
- * @deprecated With the field it answers. `writeNote` answers a visitor with
664
- * the note's `id` and `createdAt`.
665
- */
666
- export interface FoirAnonymousNoteReceipt {
667
- /**
668
- * A reference for this submission, to quote when following it up.
669
- *
670
- * NOT a handle: there is no read that takes it. Showing it to the visitor is
671
- * the whole of its purpose.
672
- */
673
- reference: string;
674
- /** When the platform recorded the submission. */
675
- submittedAt: string;
676
- }
677
621
  export type FoirDeviceType = 'DESKTOP' | 'MOBILE' | 'TABLET';
678
622
  export type FoirPlatformType = 'IOS' | 'ANDROID' | 'WEB';
679
623
  /**
@@ -694,4 +638,19 @@ export interface FoirResolveContext {
694
638
  locale?: string | null;
695
639
  platform?: FoirPlatformType | null;
696
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;
697
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
  *
@@ -59,7 +59,7 @@ export { planFoirModelCall, planFoirNamespaceCall } from './document';
59
59
  export type { FoirCall, FoirDocumentPlan, FoirNamespaceCall, FoirNamespaceSelections, FoirRecordPath, FoirSelectionModel, FoirSelectionNamespace, FoirSelections, FoirSelectionSource, FoirSelectionUnions, } from './document';
60
60
  export { createFoirSubjectRegistry, recordFoirSubjects } from './subjects';
61
61
  export type { FoirSubject, FoirSubjectEntry, FoirSubjectOptions, FoirSubjectOrigin, FoirSubjectRegistry, } from './subjects';
62
- export type { CollabInfo, FileValue, FoirAccessGrant, FoirAccessLevel, FoirAccessSource, FoirAccessor, FoirCreateMyNoteArgs, FoirWriteNoteArgs, FoirSubmitAnonymousNoteArgs, FoirAnonymousNoteReceipt, FoirCustomerNote, FoirCustomerNoteAttachment, FoirDegraded, FoirDepthBelow, FoirDepthLevel, FoirDeviceType, FoirGranteeKind, FoirJsonValue, FoirMyAccess, FoirMyNotesArgs, FoirMyNotesResult, FoirNoteConfig, FoirNoteSubjectType, FoirPlatformType, FoirRecordOwner, FoirResolveContext, FoirResourceAccess, FoirUnexpandedRecord, FoirUpdateManyPayload, FoirVisibility, GeoPoint, Image, ImageFormat, ImageVariant, ImageVariantSet, MediaValue, Passage, Prose, ProseSpan, RecordAncestor, Reference, Richtext, Video, } from './builtins';
62
+ export type { CollabInfo, FileValue, FoirAccessGrant, FoirAccessLevel, FoirAccessSource, FoirAccessor, FoirCreateMyNoteArgs, FoirWriteNoteArgs, FoirCustomerNote, FoirCustomerNoteAttachment, FoirDegraded, FoirDepthBelow, FoirDepthLevel, FoirDeviceType, FoirGranteeKind, FoirJsonValue, FoirMyAccess, FoirMyNotesArgs, FoirMyNotesResult, FoirNoteConfig, FoirNoteSubjectType, FoirPlatformType, FoirRecordOwner, FoirResolveContext, FoirResourceAccess, FoirUnexpandedRecord, FoirUpdateManyPayload, FoirVisibility, GeoPoint, Image, ImageFormat, ImageVariant, ImageVariantSet, MediaValue, Passage, Prose, ProseSpan, RecordAncestor, Reference, Richtext, Video, } from './builtins';
63
63
  /**
64
64
  * Pre-rename spellings. The `<Name>Block` wrappers and the `<Name>Value`
65
65
  * split are the same thing as the types above — an item IS its value — so each
@@ -82,4 +82,4 @@ export type { FoirInclude, FoirModelOperation, FoirNamespaceOperation, FoirOmitt
82
82
  export { FoirDecimal, decimal } from './decimal';
83
83
  export type { FoirDecimalDivOptions, FoirDecimalInput, FoirDecimalRounding, } from './decimal';
84
84
  export { FoirRequestError, FoirAuthDowngradedError, FoirSessionExpiredError, FOIR_VISITOR_IP_HEADER, } from '../transport/foir-http';
85
- export type { FoirEnv, FoirGraphQLErrorItem, FoirTokenProvider, FoirAuthDowngrade, FoirVisitorSource, } from '../transport/foir-http';
85
+ export type { FoirEnv, FoirGraphQLErrorItem, FoirTokenProvider, FoirAuthDowngrade, FoirVisitor, FoirVisitorSource, } from '../transport/foir-http';
@@ -200,9 +200,9 @@ export interface FoirKeyClientConfig<S extends FoirSchemaDefinition = FoirSchema
200
200
  dispatch?: never;
201
201
  /**
202
202
  * Read the draft schema channel (`X-Foir-Schema-Channel: draft`). Needs a key
203
- * holding `drafts:read`: a preview environment's own `pk_`, or an `sk_`. A
204
- * public key without it is served the published copy; a secret key without
205
- * it is refused. Defaults to `'published'`.
203
+ * holding `drafts:read`: a preview environment's own key. A publishable or
204
+ * a server key without it is served the published copy. Defaults to
205
+ * `'published'`.
206
206
  */
207
207
  channel?: 'published' | 'draft';
208
208
  /**
@@ -259,21 +259,28 @@ export interface FoirKeyClientConfig<S extends FoirSchemaDefinition = FoirSchema
259
259
  */
260
260
  orgId?: string;
261
261
  /**
262
- * The address of the visitor a server rendering pages is calling for, or a
263
- * function resolving it for each request, sent as `X-Foir-Visitor-IP`. The
264
- * public API's per-visitor limit then applies to that visitor rather than to
265
- * your server, which otherwise counts as one visitor for every page it
266
- * renders.
267
- *
268
- * Give it the address your host guarantees: the one its edge sets and a
269
- * visitor cannot. A header a visitor can write lets them name a fresh address
270
- * per request, which is exactly what the limit exists to stop. The docs list
271
- * the value for each common host.
272
- *
273
- * A secret key only, and one holding `visitors:forward`: the API refuses the
274
- * header, naming the permission, from any other. A client built from a public
275
- * key is refused `visitor` here, because a browser already calls as its own
276
- * visitor and a public key cannot hold the permission.
262
+ * The visitor a server rendering pages is calling for, or a function
263
+ * resolving them for each request: their address, and where they are.
264
+ *
265
+ * The address is sent as `X-Foir-Visitor-IP`. The public API's per-visitor
266
+ * limit then applies to that visitor rather than to your server, which
267
+ * otherwise counts as one visitor for every page it renders. A string is the
268
+ * address alone.
269
+ *
270
+ * The country and the coordinates are attached to every read, in its
271
+ * `contexts`, so that location targeting is decided by where the visitor is.
272
+ * Through a key holding `visitors:forward` the API never uses where the
273
+ * caller is: a read that says no place is a visitor nobody could place.
274
+ *
275
+ * Give it what your host guarantees: the values its edge sets and a visitor
276
+ * cannot. A header a visitor can write lets them name a fresh address per
277
+ * request, which is exactly what the limit exists to stop. The docs list
278
+ * the values for each common host.
279
+ *
280
+ * A server key only, and one holding `visitors:forward`: the API refuses the
281
+ * header, naming the permission, from any other. A client built from a
282
+ * publishable key is refused `visitor` here, because a browser already calls
283
+ * as its own visitor and a publishable key cannot hold the permission.
277
284
  */
278
285
  visitor?: FoirVisitorSource;
279
286
  /**
package/dist/data.js CHANGED
@@ -98,6 +98,28 @@ function refuseCredentialHeaders(extra) {
98
98
  }
99
99
  }
100
100
  var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
101
+ function sentCoordinate(value) {
102
+ return Math.round(value * 100) / 100;
103
+ }
104
+ async function resolveFoirVisitor(source) {
105
+ const value = typeof source === "function" ? await source() : source;
106
+ if (value === null || value === void 0) return { place: {} };
107
+ if (typeof value === "string") {
108
+ const address2 = value.trim();
109
+ return { address: address2 || void 0, place: {} };
110
+ }
111
+ const address = value.address?.trim();
112
+ const country = value.country?.trim();
113
+ const place = {};
114
+ if (country) place.country = country;
115
+ if (Number.isFinite(value.latitude) && Number.isFinite(value.longitude)) {
116
+ place.location = {
117
+ latitude: sentCoordinate(value.latitude),
118
+ longitude: sentCoordinate(value.longitude)
119
+ };
120
+ }
121
+ return { address: address || void 0, place };
122
+ }
101
123
  function buildFoirHeaders(input) {
102
124
  const headers = {
103
125
  "Content-Type": "application/json"
@@ -214,6 +236,30 @@ function readFoirEnvelope(json, options) {
214
236
  return json.data;
215
237
  }
216
238
 
239
+ // src/transport/key-kind.ts
240
+ var KEY_PREFIXES = [
241
+ ["foir_pub_", "publishable"],
242
+ ["foir_srv_", "server"],
243
+ ["foir_adm_", "admin"],
244
+ ["pk_", "publishable"],
245
+ ["sk_", "older"],
246
+ ["tc_", "older"]
247
+ ];
248
+ var FOIR_CREDENTIALS_DOC = "https://docs.foir.dev/credentials";
249
+ function foirKeyKind(apiKey) {
250
+ if (typeof apiKey !== "string") return void 0;
251
+ for (const [prefix, kind] of KEY_PREFIXES) {
252
+ if (apiKey.startsWith(prefix)) return kind;
253
+ }
254
+ return void 0;
255
+ }
256
+ function adminKeyRefusal(client) {
257
+ 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}`;
258
+ }
259
+ function isPublishableFoirKey(apiKey) {
260
+ return foirKeyKind(apiKey) === "publishable";
261
+ }
262
+
217
263
  // src/transport/persisted-query.ts
218
264
  var FOIR_CDN_GRAPHQL_PATH = "/cdn/graphql";
219
265
  function persistedQueryExtension(hash) {
@@ -257,10 +303,7 @@ function foirDataEndpoint(apiUrl, path) {
257
303
  return foirApiEndpoint(apiUrl, path);
258
304
  }
259
305
  async function resolveVisitorAddress(config) {
260
- const source = config.visitor;
261
- const value = typeof source === "function" ? await source() : source;
262
- const address = value?.trim();
263
- return address ? address : void 0;
306
+ return (await resolveFoirVisitor(config.visitor)).address;
264
307
  }
265
308
  async function resolveBearerToken(config) {
266
309
  const source = config.bearerToken;
@@ -309,9 +352,13 @@ function assertFoirCredential(config) {
309
352
  "[foir] createFoirClient needs both apiUrl and apiKey (or, inside an operation endpoint, apiUrl and dispatch)"
310
353
  );
311
354
  }
312
- if (loose.visitor !== void 0 && !loose.apiKey.startsWith("sk_")) {
355
+ const kind = foirKeyKind(loose.apiKey);
356
+ if (kind === "admin") {
357
+ throw new FoirRequestError(adminKeyRefusal("createFoirClient"));
358
+ }
359
+ if (loose.visitor !== void 0 && kind !== "server" && kind !== "older") {
313
360
  throw new FoirRequestError(
314
- "[foir] createFoirClient: `visitor` names the visitor a server calls for, and only a secret key holding visitors:forward may send it. A public key already calls as its visitor, and cannot hold the permission. Drop `visitor`, or build this client from your server's secret key."
361
+ `[foir] createFoirClient: \`visitor\` names the visitor a server calls for, and only a server key holding visitors:forward may send it. A publishable key already calls as its visitor, and cannot hold the permission. Drop \`visitor\`, or build this client from your server key on the server that renders your pages. See ${FOIR_CREDENTIALS_DOC}`
315
362
  );
316
363
  }
317
364
  if (operation !== void 0) {
@@ -373,7 +420,7 @@ function contextOperation(context) {
373
420
  return void 0;
374
421
  }
375
422
  function foirRoute(input) {
376
- if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
423
+ if (!isPublishableFoirKey(input.apiKey)) return { lane: "origin", reason: "key" };
377
424
  if (input.bearerToken) return { lane: "origin", reason: "bearer" };
378
425
  if (input.orgId || input.orgUrl) return { lane: "origin", reason: "org" };
379
426
  if (input.mutation) return { lane: "origin", reason: "operation" };
@@ -894,13 +941,19 @@ function planFoirModelCall(source, call) {
894
941
  }
895
942
  return planCall(source, opDef, `${call.model}.${call.op}`, operationName(model.typeName, call.op), call);
896
943
  }
897
- var CREATING_OPS = /* @__PURE__ */ new Set(["create", "submit"]);
944
+ var CREATING_OPS = /* @__PURE__ */ new Set(["create"]);
898
945
  function withCreateIdempotencyKey(source, model, op, args) {
899
946
  if (!CREATING_OPS.has(op) || args.idempotencyKey !== void 0) return args;
900
947
  const argTypes = source.models[model]?.ops?.[op]?.argTypes;
901
948
  if (!argTypes || !("idempotencyKey" in argTypes)) return args;
902
949
  return { ...args, idempotencyKey: globalThis.crypto.randomUUID() };
903
950
  }
951
+ function withStatedVisitor(op, args, place) {
952
+ if (place.country === void 0 && place.location === void 0) return args;
953
+ if (!op?.argTypes || !("contexts" in op.argTypes)) return args;
954
+ const said = args.contexts ?? {};
955
+ return { ...args, contexts: { ...place, ...said } };
956
+ }
904
957
  function planFoirNamespaceCall(source, call) {
905
958
  const namespace = source.namespaces?.[call.namespace];
906
959
  if (!namespace) {
@@ -2260,20 +2313,43 @@ function createFoirClient(clientConfig) {
2260
2313
  detectedDrift
2261
2314
  );
2262
2315
  };
2316
+ const visitorOfThisRequest = async () => {
2317
+ if (config.dispatch !== void 0 || config.visitor === void 0) {
2318
+ return { config, place: {} };
2319
+ }
2320
+ const { address, place } = await resolveFoirVisitor(config.visitor);
2321
+ return {
2322
+ config: {
2323
+ ...config,
2324
+ visitor: {
2325
+ address,
2326
+ country: place.country,
2327
+ latitude: place.location?.latitude,
2328
+ longitude: place.location?.longitude
2329
+ }
2330
+ },
2331
+ place
2332
+ };
2333
+ };
2263
2334
  const runModel = async (model, op, query) => {
2264
2335
  assertStillServed(`${model}.${op}`, (served) => served.modelCall(model, op, query?.include));
2265
2336
  const { args, include, omit, pick, depth } = splitQuery(query);
2266
2337
  const source = await selectionSource();
2338
+ const visitor = await visitorOfThisRequest();
2267
2339
  const plan = planFoirModelCall(source, {
2268
2340
  model,
2269
2341
  op,
2270
- args: withCreateIdempotencyKey(source, model, op, args),
2342
+ args: withStatedVisitor(
2343
+ source.models[model]?.ops?.[op],
2344
+ withCreateIdempotencyKey(source, model, op, args),
2345
+ visitor.place
2346
+ ),
2271
2347
  include,
2272
2348
  omit,
2273
2349
  pick,
2274
2350
  depth
2275
2351
  });
2276
- const result = await executeFoirDocument(config, plan);
2352
+ const result = await executeFoirDocument(visitor.config, plan);
2277
2353
  recordFoirSubjects(config.subjects, plan, result);
2278
2354
  maybeCheckDrift();
2279
2355
  return result;
@@ -2284,16 +2360,18 @@ function createFoirClient(clientConfig) {
2284
2360
  (served) => served.namespaceCall(namespace, op, query?.include)
2285
2361
  );
2286
2362
  const { args, include, omit, pick, depth } = splitQuery(query);
2287
- const plan = planFoirNamespaceCall(await selectionSource(), {
2363
+ const source = await selectionSource();
2364
+ const visitor = await visitorOfThisRequest();
2365
+ const plan = planFoirNamespaceCall(source, {
2288
2366
  namespace,
2289
2367
  op,
2290
- args,
2368
+ args: withStatedVisitor(source.namespaces?.[namespace]?.ops?.[op], args, visitor.place),
2291
2369
  include,
2292
2370
  omit,
2293
2371
  pick,
2294
2372
  depth
2295
2373
  });
2296
- const result = await executeFoirDocument(config, plan);
2374
+ const result = await executeFoirDocument(visitor.config, plan);
2297
2375
  recordFoirSubjects(config.subjects, plan, result);
2298
2376
  maybeCheckDrift();
2299
2377
  return result;