@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.
- package/CHANGELOG.md +330 -0
- package/dist/app/client.d.ts +109 -21
- package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +223 -211
- package/dist/app/errors.d.ts +2 -2
- package/dist/app/index.d.ts +4 -4
- package/dist/app/manifest.d.ts +1 -1
- package/dist/app/vocabulary.generated.d.ts +13 -10
- package/dist/app-contract.js +27 -31
- package/dist/app.js +80 -23
- package/dist/auth/access.d.ts +106 -0
- package/dist/auth/grant-table.generated.d.ts +13 -0
- package/dist/auth/grants.d.ts +38 -0
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/operations.d.ts +6 -1
- package/dist/auth/ops.d.ts +1 -0
- package/dist/auth.js +321 -7
- package/dist/client/platform-client.d.ts +1 -1
- package/dist/client.js +28 -1
- package/dist/codegen/index.js +1 -1
- package/dist/data/builtins.d.ts +57 -42
- package/dist/data/document.d.ts +14 -0
- package/dist/data/index.d.ts +2 -2
- package/dist/data/transport.d.ts +27 -20
- package/dist/data.js +91 -13
- package/dist/index.js +24 -0
- package/dist/next.js +47 -5
- package/dist/placement/index.d.ts +26 -11
- package/dist/placement.js +219 -2
- package/dist/push.js +55 -6
- package/dist/react/FoirProvider.d.ts +40 -1
- package/dist/react/feedback-notes.d.ts +1 -1
- package/dist/react/index.d.ts +5 -4
- package/dist/react.js +391 -8
- package/dist/server/events-client.d.ts +7 -7
- package/dist/server/index.d.ts +1 -1
- package/dist/server/require-scoped-token.d.ts +1 -1
- package/dist/server/server-client.d.ts +1 -1
- package/dist/server/verify-scoped-token.d.ts +18 -23
- package/dist/server.js +228 -14
- package/dist/transport/foir-http.d.ts +44 -5
- package/dist/transport/key-kind.d.ts +40 -0
- package/package.json +6 -5
package/dist/auth/ops.d.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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: '
|
|
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
|
|
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),
|
package/dist/codegen/index.js
CHANGED
|
@@ -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
|
-
// (
|
|
18
|
+
// (ignored by an app's key that does not hold drafts:read).
|
|
19
19
|
"X-Foir-Schema-Channel": "draft"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/dist/data/builtins.d.ts
CHANGED
|
@@ -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:
|
|
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
|
-
/**
|
|
555
|
-
|
|
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
|
-
*
|
|
595
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
610
|
-
|
|
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
|
-
*
|
|
611
|
+
* Files to submit with this note, already uploaded through
|
|
612
|
+
* `createFileUpload` / `confirmFileUpload`.
|
|
613
613
|
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/dist/data/document.d.ts
CHANGED
|
@@ -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
|
*
|