@foir/sdk 0.9.0 → 0.11.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 +719 -27
- package/README.md +121 -2
- package/dist/auth/auth.d.ts +72 -1
- package/dist/auth/hosted.d.ts +71 -4
- package/dist/auth/index.d.ts +3 -3
- package/dist/auth/operations.d.ts +84 -0
- package/dist/auth/ops.d.ts +18 -0
- package/dist/auth.js +209 -47
- package/dist/client.js +101 -13
- package/dist/codegen/block-registry.d.ts +1 -1
- package/dist/codegen/block-scaffold.d.ts +1 -1
- package/dist/codegen/index.d.ts +1 -1
- package/dist/codegen/item-registry.d.ts +1 -1
- package/dist/codegen/item-scaffold.d.ts +1 -1
- package/dist/codegen/preset.d.ts +3 -2
- package/dist/content/content-renderer.d.ts +4 -5
- package/dist/content/index.d.ts +1 -1
- package/dist/content/richtext.d.ts +1 -1
- package/dist/data/builtins.d.ts +81 -13
- package/dist/data/client.d.ts +92 -9
- package/dist/data/decimal.d.ts +212 -0
- package/dist/data/document.d.ts +14 -1
- package/dist/data/index.d.ts +22 -10
- package/dist/data/manifest.d.ts +40 -4
- package/dist/data/schema.d.ts +18 -11
- package/dist/data/selection.d.ts +15 -0
- package/dist/data/subscriptions.d.ts +36 -10
- package/dist/data/transport.d.ts +363 -24
- package/dist/data/ws-transport.d.ts +8 -2
- package/dist/data.js +591 -64
- package/dist/graphql/cdn-route.d.ts +1 -1
- package/dist/graphql/foir-request.d.ts +1 -1
- package/dist/index.js +0 -9
- package/dist/live/coalesce.d.ts +57 -0
- package/dist/live/drift.d.ts +58 -0
- package/dist/live/events.d.ts +13 -0
- package/dist/live/headers.d.ts +53 -0
- package/dist/live/index.d.ts +54 -0
- package/dist/live/keys.d.ts +72 -0
- package/dist/live/manager.d.ts +77 -0
- package/dist/live/react/index.d.ts +143 -0
- package/dist/live/reconcile.d.ts +82 -0
- package/dist/live/resync.d.ts +59 -0
- package/dist/live/store.d.ts +134 -0
- package/dist/live/tagIndex.d.ts +29 -0
- package/dist/live/tanstack.d.ts +127 -0
- package/dist/live/writetoken.d.ts +86 -0
- package/dist/live-react.js +395 -0
- package/dist/live.js +845 -0
- package/dist/next/cacheTags.d.ts +245 -0
- package/dist/next/index.d.ts +7 -0
- package/dist/next.js +260 -34
- package/dist/push.js +163 -36
- package/dist/react.js +157 -33
- package/dist/realtime/index.d.ts +4 -5
- package/dist/server/events-client.d.ts +54 -4
- package/dist/server/index.d.ts +4 -2
- package/dist/server/operation-types.d.ts +86 -58
- package/dist/server/read-dispatch.d.ts +53 -0
- package/dist/server/require-scoped-token.d.ts +20 -1
- package/dist/server.js +602 -10
- package/dist/transport/foir-http.d.ts +168 -15
- package/package.json +10 -2
package/dist/push.js
CHANGED
|
@@ -11,11 +11,30 @@ function isFoirPreview(env) {
|
|
|
11
11
|
function resolveFoirApiKey(env) {
|
|
12
12
|
return isFoirPreview(env) ? env.PLATFORM_PREVIEW_API_KEY ?? env.PLATFORM_API_KEY : env.PLATFORM_API_KEY;
|
|
13
13
|
}
|
|
14
|
+
var MALFORMED_PRUNE_REASONS = /* @__PURE__ */ new Set([
|
|
15
|
+
"unknown_field",
|
|
16
|
+
"subselections_on_scalar",
|
|
17
|
+
"missing_subselections"
|
|
18
|
+
]);
|
|
14
19
|
var FoirRequestError = class extends Error {
|
|
15
20
|
/** HTTP status code, when the request failed at the HTTP layer. */
|
|
16
21
|
status;
|
|
17
22
|
/** GraphQL errors, when the request failed with a no-data error envelope. */
|
|
18
23
|
graphQLErrors;
|
|
24
|
+
/**
|
|
25
|
+
* The `data` the server did return, when a STRICT read refused a partial
|
|
26
|
+
* response. Present only on that path: without it, opting into strictness
|
|
27
|
+
* would trade a silent wrong answer for a thrown one that discards the answer,
|
|
28
|
+
* and a caller that wants to log what it nearly rendered could not.
|
|
29
|
+
*/
|
|
30
|
+
partialData;
|
|
31
|
+
/**
|
|
32
|
+
* The selections the server stripped, when the refusal was a pruned document
|
|
33
|
+
* rather than a partial error. Same purpose as `partialData`: a caller that
|
|
34
|
+
* wants to log what it nearly rendered, or name the offending path in a
|
|
35
|
+
* developer-facing message, has it without re-parsing anything.
|
|
36
|
+
*/
|
|
37
|
+
prunedSelections;
|
|
19
38
|
cause;
|
|
20
39
|
constructor(message, options) {
|
|
21
40
|
super(message);
|
|
@@ -23,6 +42,8 @@ var FoirRequestError = class extends Error {
|
|
|
23
42
|
this.cause = options?.cause;
|
|
24
43
|
this.status = options?.status;
|
|
25
44
|
this.graphQLErrors = options?.graphQLErrors;
|
|
45
|
+
this.partialData = options?.partialData;
|
|
46
|
+
this.prunedSelections = options?.prunedSelections;
|
|
26
47
|
}
|
|
27
48
|
/**
|
|
28
49
|
* The code of the first error, if the response carried one.
|
|
@@ -82,16 +103,46 @@ function readFoirRetryAfterMs(headers) {
|
|
|
82
103
|
const seconds = Number(headers.get("Retry-After"));
|
|
83
104
|
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
|
|
84
105
|
}
|
|
106
|
+
var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
|
|
107
|
+
"x-api-key",
|
|
108
|
+
"authorization",
|
|
109
|
+
"x-foir-token",
|
|
110
|
+
"x-foir-context"
|
|
111
|
+
]);
|
|
112
|
+
function refuseCredentialHeaders(extra) {
|
|
113
|
+
if (!extra) return;
|
|
114
|
+
for (const name of Object.keys(extra)) {
|
|
115
|
+
if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
|
|
116
|
+
throw new FoirRequestError(
|
|
117
|
+
`[foir] a client built from a dispatch token cannot send "${name}" in \`headers\`. The dispatch token is the credential, and an api key or a bearer beside it would make the request two actors at once. Build a separate client for the other credential.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
85
122
|
function buildFoirHeaders(input) {
|
|
86
123
|
const headers = {
|
|
87
|
-
"Content-Type": "application/json"
|
|
88
|
-
"x-api-key": input.apiKey
|
|
124
|
+
"Content-Type": "application/json"
|
|
89
125
|
};
|
|
90
|
-
if (input.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
126
|
+
if (input.dispatchToken !== void 0) {
|
|
127
|
+
refuseCredentialHeaders(input.extra);
|
|
128
|
+
headers["X-Foir-Token"] = input.dispatchToken;
|
|
129
|
+
if (input.dispatchContext) {
|
|
130
|
+
headers["X-Foir-Context"] = input.dispatchContext;
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
headers["x-api-key"] = input.apiKey;
|
|
134
|
+
if (input.draftChannel) {
|
|
135
|
+
headers["X-Foir-Schema-Channel"] = "draft";
|
|
136
|
+
}
|
|
137
|
+
if (input.bearerToken) {
|
|
138
|
+
headers["Authorization"] = `Bearer ${input.bearerToken}`;
|
|
139
|
+
}
|
|
140
|
+
if (input.orgId) {
|
|
141
|
+
headers["X-Org-Id"] = input.orgId;
|
|
142
|
+
}
|
|
143
|
+
if (input.orgUrl) {
|
|
144
|
+
headers["X-Org-Url"] = input.orgUrl;
|
|
145
|
+
}
|
|
95
146
|
}
|
|
96
147
|
if (input.extra) {
|
|
97
148
|
for (const [key, value] of Object.entries(input.extra)) {
|
|
@@ -107,14 +158,48 @@ function foirApiEndpoint(apiUrl, path) {
|
|
|
107
158
|
}
|
|
108
159
|
return base + path;
|
|
109
160
|
}
|
|
110
|
-
|
|
161
|
+
var warnedDowngrade = null;
|
|
162
|
+
function reportAuthDowngrade(envelope) {
|
|
163
|
+
const downgrade = envelope.extensions?.auth;
|
|
164
|
+
if (!downgrade) {
|
|
165
|
+
warnedDowngrade = null;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (warnedDowngrade === downgrade.reason) return;
|
|
169
|
+
warnedDowngrade = downgrade.reason;
|
|
170
|
+
console.warn(
|
|
171
|
+
`[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}) \u2014 this request was served as ${downgrade.servedAs}, so auth-targeted content resolved to its anonymous variant. ` + (downgrade.reason === "expired" ? "Re-exchange the token and retry." : "Check which credential you are sending.")
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
function refusePrunedDocument(json, options) {
|
|
175
|
+
const selections = json.extensions?.pruned?.selections;
|
|
176
|
+
if (!selections?.length) return;
|
|
177
|
+
const describe = (s) => `${s.path}${s.type ? ` on ${s.type}` : ""} (${s.reason})`;
|
|
178
|
+
const malformed = selections.filter((s) => MALFORMED_PRUNE_REASONS.has(s.reason));
|
|
179
|
+
if (malformed.length && options?.strict !== false) {
|
|
180
|
+
throw new FoirRequestError(
|
|
181
|
+
`[foir] the server could not serve part of this query and returned the rest without it: ${malformed.map(describe).join(", ")}. Those selections do not exist on the schema this project is serving. Pass strict: false to get the partial answer instead of this error.`,
|
|
182
|
+
{ prunedSelections: selections }
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
console.warn(
|
|
186
|
+
`[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
function answeredAnything(data) {
|
|
190
|
+
return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
|
|
191
|
+
}
|
|
192
|
+
function readFoirEnvelope(json, options) {
|
|
193
|
+
reportAuthDowngrade(json);
|
|
194
|
+
refusePrunedDocument(json, options);
|
|
111
195
|
if (json.errors?.length) {
|
|
112
196
|
const msg = json.errors.map((e) => e.message).join("; ");
|
|
113
|
-
if (json.data) {
|
|
197
|
+
if (answeredAnything(json.data) && !options?.strict) {
|
|
114
198
|
console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
|
|
115
199
|
} else {
|
|
116
200
|
throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
|
|
117
|
-
graphQLErrors: json.errors
|
|
201
|
+
graphQLErrors: json.errors,
|
|
202
|
+
partialData: json.data ?? void 0
|
|
118
203
|
});
|
|
119
204
|
}
|
|
120
205
|
}
|
|
@@ -155,8 +240,9 @@ async function hasFoirErrorCode(res, ...codes) {
|
|
|
155
240
|
}
|
|
156
241
|
|
|
157
242
|
// src/graphql/cdn-route.ts
|
|
158
|
-
function isCdnEligible(env, documentText, bearerToken) {
|
|
243
|
+
function isCdnEligible(env, documentText, bearerToken, orgId) {
|
|
159
244
|
if (bearerToken) return false;
|
|
245
|
+
if (orgId) return false;
|
|
160
246
|
if (isFoirPreview(env)) return false;
|
|
161
247
|
const apiKey = resolveFoirApiKey(env);
|
|
162
248
|
if (!apiKey?.startsWith("pk_")) return false;
|
|
@@ -175,12 +261,14 @@ async function foirRequest(env, document, variables, options) {
|
|
|
175
261
|
const headers = buildFoirHeaders({
|
|
176
262
|
apiKey,
|
|
177
263
|
draftChannel: isFoirPreview(env),
|
|
178
|
-
bearerToken: options?.bearerToken
|
|
264
|
+
bearerToken: options?.bearerToken,
|
|
265
|
+
orgId: options?.orgId,
|
|
266
|
+
orgUrl: options?.orgUrl
|
|
179
267
|
});
|
|
180
268
|
const documentText = print2(document);
|
|
181
269
|
let res;
|
|
182
270
|
try {
|
|
183
|
-
if (isCdnEligible(env, documentText, options?.bearerToken)) {
|
|
271
|
+
if (isCdnEligible(env, documentText, options?.bearerToken, options?.orgId ?? options?.orgUrl)) {
|
|
184
272
|
const hash = await documentHash(documentText);
|
|
185
273
|
let shared = await fetch(buildCdnUrl(endpoint, apiKey, hash, variables), {
|
|
186
274
|
method: "GET",
|
|
@@ -736,10 +824,44 @@ async function resolveBearerToken(config) {
|
|
|
736
824
|
if (typeof source === "function") return await source() ?? void 0;
|
|
737
825
|
return source ?? void 0;
|
|
738
826
|
}
|
|
827
|
+
async function resolveDispatchToken(dispatch) {
|
|
828
|
+
const token = (await dispatch.token())?.trim();
|
|
829
|
+
if (!token) {
|
|
830
|
+
throw new FoirRequestError(
|
|
831
|
+
"[foir] the dispatch token provider returned no token. A client built from a dispatch calls Foir as that operation and holds no other credential. Build it from the token the endpoint was handed: `(await requireScopedToken(request)).dispatch`."
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
return token;
|
|
835
|
+
}
|
|
836
|
+
async function resolveFoirAuth(config) {
|
|
837
|
+
if (config.dispatch !== void 0) {
|
|
838
|
+
return {
|
|
839
|
+
headers: buildFoirHeaders({
|
|
840
|
+
dispatchToken: await resolveDispatchToken(config.dispatch),
|
|
841
|
+
dispatchContext: config.dispatch.context ?? void 0,
|
|
842
|
+
extra: config.headers
|
|
843
|
+
})
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
const bearerToken = await resolveBearerToken(config);
|
|
847
|
+
return {
|
|
848
|
+
headers: buildFoirHeaders({
|
|
849
|
+
apiKey: config.apiKey,
|
|
850
|
+
draftChannel: config.channel === "draft",
|
|
851
|
+
bearerToken,
|
|
852
|
+
orgId: config.orgId,
|
|
853
|
+
orgUrl: config.orgUrl,
|
|
854
|
+
extra: config.headers
|
|
855
|
+
}),
|
|
856
|
+
key: { apiKey: config.apiKey, bearerToken }
|
|
857
|
+
};
|
|
858
|
+
}
|
|
739
859
|
function foirRoute(input) {
|
|
740
860
|
if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
|
|
741
861
|
if (input.bearerToken) return { lane: "origin", reason: "bearer" };
|
|
862
|
+
if (input.orgId || input.orgUrl) return { lane: "origin", reason: "org" };
|
|
742
863
|
if (input.mutation) return { lane: "origin", reason: "operation" };
|
|
864
|
+
if (input.writeToken) return { lane: "origin", reason: "floor" };
|
|
743
865
|
if (input.channel === "draft") return { lane: "origin", reason: "channel" };
|
|
744
866
|
return { lane: "shared" };
|
|
745
867
|
}
|
|
@@ -752,23 +874,19 @@ async function executeFoirDocument(config, plan) {
|
|
|
752
874
|
}
|
|
753
875
|
return data[plan.rootField];
|
|
754
876
|
}
|
|
755
|
-
async function runFoirDocument(config, document, mutation, variables) {
|
|
877
|
+
async function runFoirDocument(config, document, mutation, variables, options) {
|
|
756
878
|
const doFetch = config.fetch ?? globalThis.fetch;
|
|
757
879
|
const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
|
|
758
|
-
const
|
|
759
|
-
const headers = buildFoirHeaders({
|
|
760
|
-
apiKey: config.apiKey,
|
|
761
|
-
draftChannel: config.channel === "draft",
|
|
762
|
-
bearerToken,
|
|
763
|
-
extra: config.headers
|
|
764
|
-
});
|
|
880
|
+
const { headers, key } = await resolveFoirAuth(config);
|
|
765
881
|
const hash = await documentHash(document);
|
|
766
|
-
const
|
|
767
|
-
apiKey:
|
|
768
|
-
bearerToken,
|
|
882
|
+
const sharedKey = key !== void 0 && foirRoute({
|
|
883
|
+
apiKey: key.apiKey,
|
|
884
|
+
bearerToken: key.bearerToken,
|
|
885
|
+
orgId: config.orgId,
|
|
886
|
+
orgUrl: config.orgUrl,
|
|
769
887
|
mutation,
|
|
770
888
|
channel: config.channel
|
|
771
|
-
});
|
|
889
|
+
}).lane === "shared" ? key.apiKey : void 0;
|
|
772
890
|
const post = (body) => doFetch(endpoint, {
|
|
773
891
|
method: "POST",
|
|
774
892
|
headers,
|
|
@@ -778,8 +896,8 @@ async function runFoirDocument(config, document, mutation, variables) {
|
|
|
778
896
|
const extensions = persistedQueryExtension(hash);
|
|
779
897
|
let res;
|
|
780
898
|
try {
|
|
781
|
-
if (
|
|
782
|
-
res = await doFetch(buildCdnUrl(endpoint,
|
|
899
|
+
if (sharedKey !== void 0) {
|
|
900
|
+
res = await doFetch(buildCdnUrl(endpoint, sharedKey, hash, variables), {
|
|
783
901
|
method: "GET",
|
|
784
902
|
headers,
|
|
785
903
|
...config.requestInit
|
|
@@ -805,7 +923,7 @@ async function runFoirDocument(config, document, mutation, variables) {
|
|
|
805
923
|
});
|
|
806
924
|
}
|
|
807
925
|
const json = await res.json();
|
|
808
|
-
return readFoirEnvelope(json);
|
|
926
|
+
return readFoirEnvelope(json, options);
|
|
809
927
|
}
|
|
810
928
|
async function readErrorBody(res) {
|
|
811
929
|
try {
|
|
@@ -817,19 +935,16 @@ async function readErrorBody(res) {
|
|
|
817
935
|
}
|
|
818
936
|
|
|
819
937
|
// src/data/manifest.ts
|
|
820
|
-
async function fetchFoirManifest(config) {
|
|
821
|
-
const
|
|
938
|
+
async function fetchFoirManifest(config, options = {}) {
|
|
939
|
+
const base = foirDataEndpoint(config.apiUrl, "/v1/manifest");
|
|
940
|
+
const url = options.operation === void 0 ? base : `${base}?operation=${encodeURIComponent(options.operation)}`;
|
|
822
941
|
const doFetch = config.fetch ?? globalThis.fetch;
|
|
942
|
+
const { headers } = await resolveFoirAuth(config);
|
|
823
943
|
let res;
|
|
824
944
|
try {
|
|
825
945
|
res = await doFetch(url, {
|
|
826
946
|
method: "GET",
|
|
827
|
-
headers
|
|
828
|
-
apiKey: config.apiKey,
|
|
829
|
-
draftChannel: config.channel === "draft",
|
|
830
|
-
bearerToken: await resolveBearerToken(config),
|
|
831
|
-
extra: config.headers
|
|
832
|
-
}),
|
|
947
|
+
headers,
|
|
833
948
|
...config.requestInit
|
|
834
949
|
});
|
|
835
950
|
} catch (err) {
|
|
@@ -869,6 +984,16 @@ var AUTH_OPS = {
|
|
|
869
984
|
customerUpdatePassword: "customerUpdatePassword",
|
|
870
985
|
customerVerifyEmail: "customerVerifyEmail",
|
|
871
986
|
deleteMyAccount: "deleteMyAccount",
|
|
987
|
+
// The operator's organization context. TWO ops and no third: one to read
|
|
988
|
+
// which organizations the caller may stand in, one to say which. There is
|
|
989
|
+
// deliberately no per-call org argument anywhere in this SDK — the claim the
|
|
990
|
+
// row layer reads is resolved from the caller's own membership row on every
|
|
991
|
+
// request, so an application that never names an org cannot name the wrong
|
|
992
|
+
// one.
|
|
993
|
+
myOrganizations: "myOrganizations",
|
|
994
|
+
selectMyOrganization: "selectMyOrganization",
|
|
995
|
+
// Founding one. Present in a manifest only when the project opted in.
|
|
996
|
+
registerOperatorOrganization: "registerOperatorOrganization",
|
|
872
997
|
// External identity providers.
|
|
873
998
|
customerLoginWithProvider: "customerLoginWithProvider",
|
|
874
999
|
customerProviderCallback: "customerProviderCallback",
|
|
@@ -890,6 +1015,8 @@ function authClientConfig(env, options) {
|
|
|
890
1015
|
apiKey,
|
|
891
1016
|
channel: isFoirPreview(env) ? "draft" : "published",
|
|
892
1017
|
bearerToken: options?.bearerToken,
|
|
1018
|
+
orgId: options?.orgId,
|
|
1019
|
+
orgUrl: options?.orgUrl,
|
|
893
1020
|
headers: options?.headers,
|
|
894
1021
|
fetch: options?.fetch
|
|
895
1022
|
};
|
package/dist/react.js
CHANGED
|
@@ -24,11 +24,30 @@ function isFoirPreview(env) {
|
|
|
24
24
|
function resolveFoirApiKey(env) {
|
|
25
25
|
return isFoirPreview(env) ? env.PLATFORM_PREVIEW_API_KEY ?? env.PLATFORM_API_KEY : env.PLATFORM_API_KEY;
|
|
26
26
|
}
|
|
27
|
+
var MALFORMED_PRUNE_REASONS = /* @__PURE__ */ new Set([
|
|
28
|
+
"unknown_field",
|
|
29
|
+
"subselections_on_scalar",
|
|
30
|
+
"missing_subselections"
|
|
31
|
+
]);
|
|
27
32
|
var FoirRequestError = class extends Error {
|
|
28
33
|
/** HTTP status code, when the request failed at the HTTP layer. */
|
|
29
34
|
status;
|
|
30
35
|
/** GraphQL errors, when the request failed with a no-data error envelope. */
|
|
31
36
|
graphQLErrors;
|
|
37
|
+
/**
|
|
38
|
+
* The `data` the server did return, when a STRICT read refused a partial
|
|
39
|
+
* response. Present only on that path: without it, opting into strictness
|
|
40
|
+
* would trade a silent wrong answer for a thrown one that discards the answer,
|
|
41
|
+
* and a caller that wants to log what it nearly rendered could not.
|
|
42
|
+
*/
|
|
43
|
+
partialData;
|
|
44
|
+
/**
|
|
45
|
+
* The selections the server stripped, when the refusal was a pruned document
|
|
46
|
+
* rather than a partial error. Same purpose as `partialData`: a caller that
|
|
47
|
+
* wants to log what it nearly rendered, or name the offending path in a
|
|
48
|
+
* developer-facing message, has it without re-parsing anything.
|
|
49
|
+
*/
|
|
50
|
+
prunedSelections;
|
|
32
51
|
cause;
|
|
33
52
|
constructor(message, options) {
|
|
34
53
|
super(message);
|
|
@@ -36,6 +55,8 @@ var FoirRequestError = class extends Error {
|
|
|
36
55
|
this.cause = options?.cause;
|
|
37
56
|
this.status = options?.status;
|
|
38
57
|
this.graphQLErrors = options?.graphQLErrors;
|
|
58
|
+
this.partialData = options?.partialData;
|
|
59
|
+
this.prunedSelections = options?.prunedSelections;
|
|
39
60
|
}
|
|
40
61
|
/**
|
|
41
62
|
* The code of the first error, if the response carried one.
|
|
@@ -74,16 +95,46 @@ var FoirRequestError = class extends Error {
|
|
|
74
95
|
return typeof seconds === "number" && seconds > 0 ? seconds * 1e3 : void 0;
|
|
75
96
|
}
|
|
76
97
|
};
|
|
98
|
+
var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
|
|
99
|
+
"x-api-key",
|
|
100
|
+
"authorization",
|
|
101
|
+
"x-foir-token",
|
|
102
|
+
"x-foir-context"
|
|
103
|
+
]);
|
|
104
|
+
function refuseCredentialHeaders(extra) {
|
|
105
|
+
if (!extra) return;
|
|
106
|
+
for (const name of Object.keys(extra)) {
|
|
107
|
+
if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
|
|
108
|
+
throw new FoirRequestError(
|
|
109
|
+
`[foir] a client built from a dispatch token cannot send "${name}" in \`headers\`. The dispatch token is the credential, and an api key or a bearer beside it would make the request two actors at once. Build a separate client for the other credential.`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
77
114
|
function buildFoirHeaders(input) {
|
|
78
115
|
const headers = {
|
|
79
|
-
"Content-Type": "application/json"
|
|
80
|
-
"x-api-key": input.apiKey
|
|
116
|
+
"Content-Type": "application/json"
|
|
81
117
|
};
|
|
82
|
-
if (input.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
118
|
+
if (input.dispatchToken !== void 0) {
|
|
119
|
+
refuseCredentialHeaders(input.extra);
|
|
120
|
+
headers["X-Foir-Token"] = input.dispatchToken;
|
|
121
|
+
if (input.dispatchContext) {
|
|
122
|
+
headers["X-Foir-Context"] = input.dispatchContext;
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
headers["x-api-key"] = input.apiKey;
|
|
126
|
+
if (input.draftChannel) {
|
|
127
|
+
headers["X-Foir-Schema-Channel"] = "draft";
|
|
128
|
+
}
|
|
129
|
+
if (input.bearerToken) {
|
|
130
|
+
headers["Authorization"] = `Bearer ${input.bearerToken}`;
|
|
131
|
+
}
|
|
132
|
+
if (input.orgId) {
|
|
133
|
+
headers["X-Org-Id"] = input.orgId;
|
|
134
|
+
}
|
|
135
|
+
if (input.orgUrl) {
|
|
136
|
+
headers["X-Org-Url"] = input.orgUrl;
|
|
137
|
+
}
|
|
87
138
|
}
|
|
88
139
|
if (input.extra) {
|
|
89
140
|
for (const [key, value] of Object.entries(input.extra)) {
|
|
@@ -99,14 +150,48 @@ function foirApiEndpoint(apiUrl, path) {
|
|
|
99
150
|
}
|
|
100
151
|
return base + path;
|
|
101
152
|
}
|
|
102
|
-
|
|
153
|
+
var warnedDowngrade = null;
|
|
154
|
+
function reportAuthDowngrade(envelope) {
|
|
155
|
+
const downgrade = envelope.extensions?.auth;
|
|
156
|
+
if (!downgrade) {
|
|
157
|
+
warnedDowngrade = null;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (warnedDowngrade === downgrade.reason) return;
|
|
161
|
+
warnedDowngrade = downgrade.reason;
|
|
162
|
+
console.warn(
|
|
163
|
+
`[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}) \u2014 this request was served as ${downgrade.servedAs}, so auth-targeted content resolved to its anonymous variant. ` + (downgrade.reason === "expired" ? "Re-exchange the token and retry." : "Check which credential you are sending.")
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
function refusePrunedDocument(json, options) {
|
|
167
|
+
const selections = json.extensions?.pruned?.selections;
|
|
168
|
+
if (!selections?.length) return;
|
|
169
|
+
const describe = (s) => `${s.path}${s.type ? ` on ${s.type}` : ""} (${s.reason})`;
|
|
170
|
+
const malformed = selections.filter((s) => MALFORMED_PRUNE_REASONS.has(s.reason));
|
|
171
|
+
if (malformed.length && options?.strict !== false) {
|
|
172
|
+
throw new FoirRequestError(
|
|
173
|
+
`[foir] the server could not serve part of this query and returned the rest without it: ${malformed.map(describe).join(", ")}. Those selections do not exist on the schema this project is serving. Pass strict: false to get the partial answer instead of this error.`,
|
|
174
|
+
{ prunedSelections: selections }
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
console.warn(
|
|
178
|
+
`[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
function answeredAnything(data) {
|
|
182
|
+
return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
|
|
183
|
+
}
|
|
184
|
+
function readFoirEnvelope(json, options) {
|
|
185
|
+
reportAuthDowngrade(json);
|
|
186
|
+
refusePrunedDocument(json, options);
|
|
103
187
|
if (json.errors?.length) {
|
|
104
188
|
const msg = json.errors.map((e) => e.message).join("; ");
|
|
105
|
-
if (json.data) {
|
|
189
|
+
if (answeredAnything(json.data) && !options?.strict) {
|
|
106
190
|
console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
|
|
107
191
|
} else {
|
|
108
192
|
throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
|
|
109
|
-
graphQLErrors: json.errors
|
|
193
|
+
graphQLErrors: json.errors,
|
|
194
|
+
partialData: json.data ?? void 0
|
|
110
195
|
});
|
|
111
196
|
}
|
|
112
197
|
}
|
|
@@ -620,10 +705,44 @@ async function resolveBearerToken(config) {
|
|
|
620
705
|
if (typeof source === "function") return await source() ?? void 0;
|
|
621
706
|
return source ?? void 0;
|
|
622
707
|
}
|
|
708
|
+
async function resolveDispatchToken(dispatch) {
|
|
709
|
+
const token = (await dispatch.token())?.trim();
|
|
710
|
+
if (!token) {
|
|
711
|
+
throw new FoirRequestError(
|
|
712
|
+
"[foir] the dispatch token provider returned no token. A client built from a dispatch calls Foir as that operation and holds no other credential. Build it from the token the endpoint was handed: `(await requireScopedToken(request)).dispatch`."
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
return token;
|
|
716
|
+
}
|
|
717
|
+
async function resolveFoirAuth(config) {
|
|
718
|
+
if (config.dispatch !== void 0) {
|
|
719
|
+
return {
|
|
720
|
+
headers: buildFoirHeaders({
|
|
721
|
+
dispatchToken: await resolveDispatchToken(config.dispatch),
|
|
722
|
+
dispatchContext: config.dispatch.context ?? void 0,
|
|
723
|
+
extra: config.headers
|
|
724
|
+
})
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
const bearerToken = await resolveBearerToken(config);
|
|
728
|
+
return {
|
|
729
|
+
headers: buildFoirHeaders({
|
|
730
|
+
apiKey: config.apiKey,
|
|
731
|
+
draftChannel: config.channel === "draft",
|
|
732
|
+
bearerToken,
|
|
733
|
+
orgId: config.orgId,
|
|
734
|
+
orgUrl: config.orgUrl,
|
|
735
|
+
extra: config.headers
|
|
736
|
+
}),
|
|
737
|
+
key: { apiKey: config.apiKey, bearerToken }
|
|
738
|
+
};
|
|
739
|
+
}
|
|
623
740
|
function foirRoute(input) {
|
|
624
741
|
if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
|
|
625
742
|
if (input.bearerToken) return { lane: "origin", reason: "bearer" };
|
|
743
|
+
if (input.orgId || input.orgUrl) return { lane: "origin", reason: "org" };
|
|
626
744
|
if (input.mutation) return { lane: "origin", reason: "operation" };
|
|
745
|
+
if (input.writeToken) return { lane: "origin", reason: "floor" };
|
|
627
746
|
if (input.channel === "draft") return { lane: "origin", reason: "channel" };
|
|
628
747
|
return { lane: "shared" };
|
|
629
748
|
}
|
|
@@ -636,23 +755,19 @@ async function executeFoirDocument(config, plan) {
|
|
|
636
755
|
}
|
|
637
756
|
return data[plan.rootField];
|
|
638
757
|
}
|
|
639
|
-
async function runFoirDocument(config, document2, mutation, variables) {
|
|
758
|
+
async function runFoirDocument(config, document2, mutation, variables, options) {
|
|
640
759
|
const doFetch = config.fetch ?? globalThis.fetch;
|
|
641
760
|
const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
|
|
642
|
-
const
|
|
643
|
-
const headers = buildFoirHeaders({
|
|
644
|
-
apiKey: config.apiKey,
|
|
645
|
-
draftChannel: config.channel === "draft",
|
|
646
|
-
bearerToken,
|
|
647
|
-
extra: config.headers
|
|
648
|
-
});
|
|
761
|
+
const { headers, key } = await resolveFoirAuth(config);
|
|
649
762
|
const hash = await documentHash(document2);
|
|
650
|
-
const
|
|
651
|
-
apiKey:
|
|
652
|
-
bearerToken,
|
|
763
|
+
const sharedKey = key !== void 0 && foirRoute({
|
|
764
|
+
apiKey: key.apiKey,
|
|
765
|
+
bearerToken: key.bearerToken,
|
|
766
|
+
orgId: config.orgId,
|
|
767
|
+
orgUrl: config.orgUrl,
|
|
653
768
|
mutation,
|
|
654
769
|
channel: config.channel
|
|
655
|
-
});
|
|
770
|
+
}).lane === "shared" ? key.apiKey : void 0;
|
|
656
771
|
const post = (body) => doFetch(endpoint, {
|
|
657
772
|
method: "POST",
|
|
658
773
|
headers,
|
|
@@ -662,8 +777,8 @@ async function runFoirDocument(config, document2, mutation, variables) {
|
|
|
662
777
|
const extensions = persistedQueryExtension(hash);
|
|
663
778
|
let res;
|
|
664
779
|
try {
|
|
665
|
-
if (
|
|
666
|
-
res = await doFetch(buildCdnUrl(endpoint,
|
|
780
|
+
if (sharedKey !== void 0) {
|
|
781
|
+
res = await doFetch(buildCdnUrl(endpoint, sharedKey, hash, variables), {
|
|
667
782
|
method: "GET",
|
|
668
783
|
headers,
|
|
669
784
|
...config.requestInit
|
|
@@ -689,7 +804,7 @@ async function runFoirDocument(config, document2, mutation, variables) {
|
|
|
689
804
|
});
|
|
690
805
|
}
|
|
691
806
|
const json = await res.json();
|
|
692
|
-
return readFoirEnvelope(json);
|
|
807
|
+
return readFoirEnvelope(json, options);
|
|
693
808
|
}
|
|
694
809
|
async function readErrorBody(res) {
|
|
695
810
|
try {
|
|
@@ -701,19 +816,16 @@ async function readErrorBody(res) {
|
|
|
701
816
|
}
|
|
702
817
|
|
|
703
818
|
// src/data/manifest.ts
|
|
704
|
-
async function fetchFoirManifest(config) {
|
|
705
|
-
const
|
|
819
|
+
async function fetchFoirManifest(config, options = {}) {
|
|
820
|
+
const base = foirDataEndpoint(config.apiUrl, "/v1/manifest");
|
|
821
|
+
const url = options.operation === void 0 ? base : `${base}?operation=${encodeURIComponent(options.operation)}`;
|
|
706
822
|
const doFetch = config.fetch ?? globalThis.fetch;
|
|
823
|
+
const { headers } = await resolveFoirAuth(config);
|
|
707
824
|
let res;
|
|
708
825
|
try {
|
|
709
826
|
res = await doFetch(url, {
|
|
710
827
|
method: "GET",
|
|
711
|
-
headers
|
|
712
|
-
apiKey: config.apiKey,
|
|
713
|
-
draftChannel: config.channel === "draft",
|
|
714
|
-
bearerToken: await resolveBearerToken(config),
|
|
715
|
-
extra: config.headers
|
|
716
|
-
}),
|
|
828
|
+
headers,
|
|
717
829
|
...config.requestInit
|
|
718
830
|
});
|
|
719
831
|
} catch (err) {
|
|
@@ -753,6 +865,16 @@ var AUTH_OPS = {
|
|
|
753
865
|
customerUpdatePassword: "customerUpdatePassword",
|
|
754
866
|
customerVerifyEmail: "customerVerifyEmail",
|
|
755
867
|
deleteMyAccount: "deleteMyAccount",
|
|
868
|
+
// The operator's organization context. TWO ops and no third: one to read
|
|
869
|
+
// which organizations the caller may stand in, one to say which. There is
|
|
870
|
+
// deliberately no per-call org argument anywhere in this SDK — the claim the
|
|
871
|
+
// row layer reads is resolved from the caller's own membership row on every
|
|
872
|
+
// request, so an application that never names an org cannot name the wrong
|
|
873
|
+
// one.
|
|
874
|
+
myOrganizations: "myOrganizations",
|
|
875
|
+
selectMyOrganization: "selectMyOrganization",
|
|
876
|
+
// Founding one. Present in a manifest only when the project opted in.
|
|
877
|
+
registerOperatorOrganization: "registerOperatorOrganization",
|
|
756
878
|
// External identity providers.
|
|
757
879
|
customerLoginWithProvider: "customerLoginWithProvider",
|
|
758
880
|
customerProviderCallback: "customerProviderCallback",
|
|
@@ -774,6 +896,8 @@ function authClientConfig(env, options) {
|
|
|
774
896
|
apiKey,
|
|
775
897
|
channel: isFoirPreview(env) ? "draft" : "published",
|
|
776
898
|
bearerToken: options?.bearerToken,
|
|
899
|
+
orgId: options?.orgId,
|
|
900
|
+
orgUrl: options?.orgUrl,
|
|
777
901
|
headers: options?.headers,
|
|
778
902
|
fetch: options?.fetch
|
|
779
903
|
};
|
package/dist/realtime/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @foir/sdk/realtime — optional thin web helper over y-websocket
|
|
2
|
+
* @foir/sdk/realtime — optional thin web helper over y-websocket.
|
|
3
3
|
*
|
|
4
4
|
* The apollo-link analogue for Foir realtime collaboration: sugar over the
|
|
5
5
|
* off-the-shelf `y-websocket` provider that adds exactly the wire-protocol
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* `yjs` + `y-websocket` are OPTIONAL peer deps gated to this subpath, so a
|
|
18
18
|
* consumer importing only other `@foir/sdk/*` exports pulls no Yjs.
|
|
19
19
|
*
|
|
20
|
-
* Wire-protocol contract: `docs/realtime-collab-contract.md
|
|
20
|
+
* Wire-protocol contract: `docs/realtime-collab-contract.md`.
|
|
21
21
|
*/
|
|
22
22
|
import type * as Y from 'yjs';
|
|
23
23
|
import { WebsocketProvider } from 'y-websocket';
|
|
@@ -36,13 +36,12 @@ export interface RoomAddress {
|
|
|
36
36
|
* with a 400).
|
|
37
37
|
*
|
|
38
38
|
* That segment is a KEY space because the platform compares it against the
|
|
39
|
-
* reserved token `live` to select an EPHEMERAL persistence policy
|
|
39
|
+
* reserved token `live` to select an EPHEMERAL persistence policy:
|
|
40
40
|
* live co-edit only — no `editing_state`, no autosave, doc destroyed on last
|
|
41
41
|
* leave. A cuid2 row id can never equal `live`, so sending an id puts that
|
|
42
42
|
* policy out of reach by construction. Worse, variants are themselves rows in
|
|
43
43
|
* `records`, so a variant id still passes the join gate: the connection
|
|
44
|
-
* succeeds and edits a DIFFERENT record, with nothing reported on either side
|
|
45
|
-
* (TTS-953).
|
|
44
|
+
* succeeds and edits a DIFFERENT record, with nothing reported on either side.
|
|
46
45
|
*
|
|
47
46
|
* The address to send is the one the API advertises — the `collab` field on
|
|
48
47
|
* every public record type returns `{ enabled, wsUrl, roomName }`, where
|