@foir/sdk 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +131 -2
  2. package/dist/app/client.d.ts +371 -0
  3. package/dist/app/contract/index.d.ts +23 -0
  4. package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +1465 -0
  5. package/dist/app/contract/proto/apps/v1/discovery_pb.d.ts +678 -0
  6. package/dist/app/dispatch.d.ts +64 -0
  7. package/dist/app/errors.d.ts +50 -0
  8. package/dist/app/index.d.ts +40 -0
  9. package/dist/app/manifest.d.ts +56 -0
  10. package/dist/app/naming.d.ts +31 -0
  11. package/dist/app/record-key.d.ts +17 -0
  12. package/dist/app/ticket.d.ts +37 -0
  13. package/dist/app/vocabulary.generated.d.ts +20 -0
  14. package/dist/app-contract.js +124 -0
  15. package/dist/app.js +998 -0
  16. package/dist/auth/auth.d.ts +24 -6
  17. package/dist/auth/index.d.ts +2 -1
  18. package/dist/auth.js +139 -23
  19. package/dist/client.js +36 -2
  20. package/dist/data/index.d.ts +2 -2
  21. package/dist/data/transport.d.ts +34 -2
  22. package/dist/data.js +77 -2
  23. package/dist/graphql/client.d.ts +1 -1
  24. package/dist/graphql/foir-request.d.ts +1 -1
  25. package/dist/next.js +70 -2
  26. package/dist/placement/index.d.ts +129 -0
  27. package/dist/placement.js +144 -0
  28. package/dist/push.js +121 -13
  29. package/dist/react.js +70 -2
  30. package/dist/server/callback-client.d.ts +30 -9
  31. package/dist/server/continue-run.d.ts +56 -0
  32. package/dist/server/index.d.ts +5 -3
  33. package/dist/server/operation-types.d.ts +28 -7
  34. package/dist/server/require-scoped-token.d.ts +52 -7
  35. package/dist/server/verify-scoped-token.d.ts +109 -3
  36. package/dist/server.js +415 -127
  37. package/dist/transport/foir-http.d.ts +48 -0
  38. package/package.json +24 -6
package/dist/app.js ADDED
@@ -0,0 +1,998 @@
1
+ // src/transport/foir-http.ts
2
+ var MALFORMED_PRUNE_REASONS = /* @__PURE__ */ new Set([
3
+ "unknown_field",
4
+ "subselections_on_scalar",
5
+ "missing_subselections"
6
+ ]);
7
+ var FoirRequestError = class extends Error {
8
+ /** HTTP status code, when the request failed at the HTTP layer. */
9
+ status;
10
+ /** GraphQL errors, when the request failed with a no-data error envelope. */
11
+ graphQLErrors;
12
+ /**
13
+ * The `data` the server did return, when a STRICT read refused a partial
14
+ * response. Present only on that path: without it, opting into strictness
15
+ * would trade a silent wrong answer for a thrown one that discards the answer,
16
+ * and a caller that wants to log what it nearly rendered could not.
17
+ */
18
+ partialData;
19
+ /**
20
+ * The selections the server stripped, when the refusal was a pruned document
21
+ * rather than a partial error. Same purpose as `partialData`: a caller that
22
+ * wants to log what it nearly rendered, or name the offending path in a
23
+ * developer-facing message, has it without re-parsing anything.
24
+ */
25
+ prunedSelections;
26
+ cause;
27
+ constructor(message, options) {
28
+ super(message);
29
+ this.name = "FoirRequestError";
30
+ this.cause = options?.cause;
31
+ this.status = options?.status;
32
+ this.graphQLErrors = options?.graphQLErrors;
33
+ this.partialData = options?.partialData;
34
+ this.prunedSelections = options?.prunedSelections;
35
+ }
36
+ /**
37
+ * The code of the first error, if the response carried one.
38
+ *
39
+ * A getter rather than a stored field so it stays correct for an error
40
+ * constructed anywhere, including by a caller in a test.
41
+ */
42
+ get code() {
43
+ return this.graphQLErrors?.[0]?.extensions?.code;
44
+ }
45
+ /** The correlation id, if the response carried one. */
46
+ get requestId() {
47
+ return this.graphQLErrors?.[0]?.extensions?.requestId;
48
+ }
49
+ /**
50
+ * Whether retrying this request could succeed.
51
+ *
52
+ * The 429 is the one that has to be in here and is easy to leave out: it sits
53
+ * among the 4xx statuses that are otherwise permanent, so a policy written as
54
+ * "retry 5xx, never retry 4xx" excludes exactly the failure that clears on its
55
+ * own. A storefront wrote that policy, its key saturated, and every read
56
+ * failed on the first attempt for an hour.
57
+ */
58
+ get retryable() {
59
+ if (this.status === void 0) {
60
+ if (this.graphQLErrors?.length || this.prunedSelections?.length) {
61
+ return this.code === "RATE_LIMITED" || this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
62
+ }
63
+ return true;
64
+ }
65
+ if (this.status === 429 || this.code === "RATE_LIMITED") return true;
66
+ if (this.status >= 500) return true;
67
+ return this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
68
+ }
69
+ /**
70
+ * How long to wait before retrying, in milliseconds, when the server said.
71
+ * Undefined when it did not, which means the caller picks its own backoff.
72
+ */
73
+ get retryAfterMs() {
74
+ const seconds = this.graphQLErrors?.[0]?.extensions?.retryAfter;
75
+ return typeof seconds === "number" && seconds > 0 ? seconds * 1e3 : void 0;
76
+ }
77
+ };
78
+ var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
79
+ "x-api-key",
80
+ "authorization",
81
+ "x-foir-token",
82
+ "x-foir-context"
83
+ ]);
84
+ function refuseCredentialHeaders(extra) {
85
+ if (!extra) return;
86
+ for (const name of Object.keys(extra)) {
87
+ if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
88
+ throw new FoirRequestError(
89
+ `[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.`
90
+ );
91
+ }
92
+ }
93
+ }
94
+ var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
95
+ function buildFoirHeaders(input) {
96
+ const headers = {
97
+ "Content-Type": "application/json"
98
+ };
99
+ if (input.dispatchToken !== void 0) {
100
+ refuseCredentialHeaders(input.extra);
101
+ headers["X-Foir-Token"] = input.dispatchToken;
102
+ if (input.dispatchContext) {
103
+ headers["X-Foir-Context"] = input.dispatchContext;
104
+ }
105
+ } else {
106
+ headers["x-api-key"] = input.apiKey;
107
+ if (input.draftChannel) {
108
+ headers["X-Foir-Schema-Channel"] = "draft";
109
+ }
110
+ if (input.bearerToken) {
111
+ headers["Authorization"] = `Bearer ${input.bearerToken}`;
112
+ }
113
+ if (input.orgId) {
114
+ headers["X-Org-Id"] = input.orgId;
115
+ }
116
+ if (input.orgUrl) {
117
+ headers["X-Org-Url"] = input.orgUrl;
118
+ }
119
+ if (input.visitorAddress) {
120
+ headers[FOIR_VISITOR_IP_HEADER] = input.visitorAddress;
121
+ }
122
+ }
123
+ if (input.extra) {
124
+ for (const [key, value] of Object.entries(input.extra)) {
125
+ headers[key] = value;
126
+ }
127
+ }
128
+ return headers;
129
+ }
130
+ function foirApiEndpoint(apiUrl, path) {
131
+ const base = apiUrl.trim().replace(/\/+$/, "").replace(/\/graphql$/i, "");
132
+ if (!base) {
133
+ throw new FoirRequestError("[foir] apiUrl is empty \u2014 pass the Foir API origin");
134
+ }
135
+ return base + path;
136
+ }
137
+ var FoirAuthDowngradedError = class extends FoirRequestError {
138
+ downgrade;
139
+ constructor(downgrade) {
140
+ super(
141
+ `[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}), so the request was served as ${downgrade.servedAs}`,
142
+ { graphQLErrors: [{ message: "customer token rejected", extensions: { code: "AUTH_DOWNGRADED" } }] }
143
+ );
144
+ this.name = "FoirAuthDowngradedError";
145
+ this.downgrade = downgrade;
146
+ }
147
+ };
148
+ var warnedDowngrade = null;
149
+ function reportAuthDowngrade(envelope) {
150
+ const downgrade = envelope.extensions?.auth;
151
+ if (!downgrade) {
152
+ warnedDowngrade = null;
153
+ return;
154
+ }
155
+ if (warnedDowngrade === downgrade.reason) return;
156
+ warnedDowngrade = downgrade.reason;
157
+ console.warn(
158
+ `[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.")
159
+ );
160
+ }
161
+ function refusePrunedDocument(json, options) {
162
+ const selections = json.extensions?.pruned?.selections;
163
+ if (!selections?.length) return;
164
+ const describe = (s) => `${s.path}${s.type ? ` on ${s.type}` : ""} (${s.reason})`;
165
+ const malformed = selections.filter((s) => MALFORMED_PRUNE_REASONS.has(s.reason));
166
+ if (malformed.length && options?.strict !== false) {
167
+ throw new FoirRequestError(
168
+ `[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.`,
169
+ { prunedSelections: selections }
170
+ );
171
+ }
172
+ console.warn(
173
+ `[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
174
+ );
175
+ }
176
+ function answeredAnything(data) {
177
+ return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
178
+ }
179
+ function readFoirEnvelope(json, options) {
180
+ if (options?.rejectDowngrade && json.extensions?.auth) {
181
+ throw new FoirAuthDowngradedError(json.extensions.auth);
182
+ }
183
+ reportAuthDowngrade(json);
184
+ refusePrunedDocument(json, options);
185
+ if (json.errors?.length) {
186
+ const msg = json.errors.map((e) => e.message).join("; ");
187
+ if (answeredAnything(json.data) && !options?.strict) {
188
+ console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
189
+ } else {
190
+ throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
191
+ graphQLErrors: json.errors,
192
+ partialData: json.data ?? void 0
193
+ });
194
+ }
195
+ }
196
+ if (!json.data) {
197
+ throw new FoirRequestError("[foir] empty GraphQL response");
198
+ }
199
+ return json.data;
200
+ }
201
+
202
+ // src/transport/retry.ts
203
+ var DEFAULT_ATTEMPTS = 3;
204
+ var DEFAULT_BASE_DELAY_MS = 200;
205
+ var DEFAULT_MAX_DELAY_MS = 2e4;
206
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
207
+ function foirRetryDelayMs(error, attempt, policy = {}) {
208
+ if (!(error instanceof FoirRequestError) || !error.retryable) return null;
209
+ const max = policy.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
210
+ const stated = error.retryAfterMs;
211
+ if (stated !== void 0) return Math.min(stated, max);
212
+ const base = policy.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
213
+ return Math.min(base * 2 ** (attempt - 1), max);
214
+ }
215
+ async function withFoirRetry(fn, policy = {}) {
216
+ const attempts = Math.max(1, policy.attempts ?? DEFAULT_ATTEMPTS);
217
+ const sleep = policy.sleep ?? defaultSleep;
218
+ for (let attempt = 1; ; attempt += 1) {
219
+ try {
220
+ return await fn();
221
+ } catch (error) {
222
+ const delayMs = attempt >= attempts ? null : foirRetryDelayMs(error, attempt, policy);
223
+ if (delayMs === null) throw error;
224
+ policy.onRetry?.({ attempt, delayMs, error });
225
+ await sleep(delayMs);
226
+ }
227
+ }
228
+ }
229
+
230
+ // src/app/errors.ts
231
+ function extensionOf(error, key) {
232
+ if (!(error instanceof FoirRequestError)) return null;
233
+ for (const entry of error.graphQLErrors ?? []) {
234
+ const value = entry.extensions?.[key];
235
+ if (value && typeof value === "object" && !Array.isArray(value)) return value;
236
+ }
237
+ return null;
238
+ }
239
+ function appLookupConflictOf(error) {
240
+ const conflict = extensionOf(error, "lookupConflict");
241
+ if (!conflict) return null;
242
+ const { model, lookup, keyBy } = conflict;
243
+ if (typeof model !== "string" || typeof lookup !== "string" || !Array.isArray(keyBy)) return null;
244
+ if (!keyBy.every((field) => typeof field === "string")) return null;
245
+ return { model, lookup, keyBy };
246
+ }
247
+ function appCustomerUnlinkedOf(error) {
248
+ const unlinked = extensionOf(error, "customerUnlinked");
249
+ if (!unlinked) return null;
250
+ const { model, identityProvider } = unlinked;
251
+ if (typeof model !== "string" || typeof identityProvider !== "string") return null;
252
+ return { model, identityProvider };
253
+ }
254
+ function isAppFieldWithheld(error, field) {
255
+ if (!(error instanceof FoirRequestError)) return false;
256
+ return (error.prunedSelections ?? []).some((selection) => {
257
+ if (selection.reason !== "unknown_field") return false;
258
+ if (field === void 0) return true;
259
+ const steps = selection.path.split(".");
260
+ return steps.length === 2 && steps[1] === field;
261
+ });
262
+ }
263
+
264
+ // src/app/naming.ts
265
+ function appModelTypeName(modelKey) {
266
+ return modelKey.split(/[^a-zA-Z0-9]+/).filter((part) => part !== "").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
267
+ }
268
+ function appModelFieldName(modelKey) {
269
+ const type = appModelTypeName(modelKey);
270
+ return type === "" ? "" : type.charAt(0).toLowerCase() + type.slice(1);
271
+ }
272
+ function appSelectorEnumValue(fieldKey) {
273
+ const cleaned = fieldKey.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
274
+ if (cleaned === "") return "_";
275
+ return /^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned;
276
+ }
277
+ function isSelectableFieldKey(fieldKey) {
278
+ return /^[_A-Za-z][_0-9A-Za-z]*$/.test(fieldKey);
279
+ }
280
+
281
+ // src/app/client.ts
282
+ var RECORD_ID_FIELD = "_id";
283
+ var RECORD_NATURAL_KEY_FIELD = "_naturalKey";
284
+ var RECORD_OWNER_FIELD = "_ownerId";
285
+ function createAppClient(config) {
286
+ if (!config.apiUrl) throw new FoirRequestError("[foir] createAppClient needs `apiUrl`, the data API origin");
287
+ if (!config.dispatch || typeof config.dispatch.token !== "function") {
288
+ throw new FoirRequestError(
289
+ "[foir] createAppClient needs `dispatch`, the token to call as: `(await requireScopedToken(request)).dispatch`"
290
+ );
291
+ }
292
+ const endpoint = foirApiEndpoint(config.apiUrl, "/graphql");
293
+ const doFetch = config.fetch ?? globalThis.fetch;
294
+ async function send(document, variables) {
295
+ const token = (await config.dispatch.token())?.trim();
296
+ if (!token) {
297
+ throw new FoirRequestError(
298
+ "[foir] the dispatch token provider returned no token. An app client calls as the token it was built from."
299
+ );
300
+ }
301
+ let res;
302
+ try {
303
+ res = await doFetch(endpoint, {
304
+ ...config.requestInit,
305
+ method: "POST",
306
+ headers: buildFoirHeaders({ dispatchToken: token, dispatchContext: config.dispatch.context ?? void 0 }),
307
+ body: JSON.stringify({ query: document, variables })
308
+ });
309
+ } catch (err) {
310
+ throw new FoirRequestError("[foir] network error", { cause: err });
311
+ }
312
+ if (!res.ok) {
313
+ let errors;
314
+ try {
315
+ const body = await res.json();
316
+ errors = body?.errors?.length ? body.errors : void 0;
317
+ } catch {
318
+ errors = void 0;
319
+ }
320
+ const detail = errors ? `: ${errors.map((e) => e.message).join("; ")}` : "";
321
+ throw new FoirRequestError(`[foir] HTTP ${res.status} ${res.statusText}${detail}`, {
322
+ status: res.status,
323
+ ...errors ? { graphQLErrors: errors } : {}
324
+ });
325
+ }
326
+ const json = await res.json();
327
+ return readFoirEnvelope(json, { strict: true });
328
+ }
329
+ const run = (document, variables) => withFoirRetry(() => send(document, variables), config.retry);
330
+ async function field(name, document, variables) {
331
+ const data = await run(document, variables);
332
+ if (!(name in data)) {
333
+ throw new FoirRequestError(`[foir] the response has no "${name}" field`);
334
+ }
335
+ return data[name];
336
+ }
337
+ const refuse = (message) => {
338
+ throw new FoirRequestError(`[foir] ${message}`);
339
+ };
340
+ async function readOne(model, by, fields) {
341
+ const byId = "id" in by;
342
+ const value = byId ? by.id : by.naturalKey;
343
+ if (value === "") refuse(`a record read on ${model} was given an empty ${byId ? "id" : "natural key"}`);
344
+ const name = appModelFieldName(model);
345
+ const selectable = fields.filter(isSelectableFieldKey);
346
+ let unreadable = fields.filter((key2) => !isSelectableFieldKey(key2));
347
+ const ask = (selection) => field(
348
+ name,
349
+ byId ? `query AppReadRecordById($id: String!) { ${name}(id: $id) { ${[RECORD_ID_FIELD, RECORD_NATURAL_KEY_FIELD, RECORD_OWNER_FIELD, ...selection].join(" ")} } }` : `query AppReadRecord($naturalKey: String!) { ${name}(naturalKey: $naturalKey) { ${[RECORD_ID_FIELD, RECORD_NATURAL_KEY_FIELD, RECORD_OWNER_FIELD, ...selection].join(" ")} } }`,
350
+ byId ? { id: value } : { naturalKey: value }
351
+ );
352
+ let answer;
353
+ try {
354
+ answer = await ask(selectable);
355
+ } catch (error) {
356
+ if (!(error instanceof FoirRequestError) || isAppFieldWithheld(error, name) || selectable.length === 0) throw error;
357
+ if (error.retryable || !error.prunedSelections?.length && !error.graphQLErrors?.length) throw error;
358
+ answer = await ask([]);
359
+ unreadable = [...fields];
360
+ }
361
+ if (!answer) return null;
362
+ const id = answer[RECORD_ID_FIELD];
363
+ if (typeof id !== "string" || id === "") refuse(`${name} answered a record carrying no id`);
364
+ const values = {};
365
+ if (unreadable.length < fields.length) {
366
+ for (const key2 of selectable) {
367
+ const got = answer[key2];
368
+ if (got !== void 0) values[key2] = got;
369
+ }
370
+ }
371
+ const key = answer[RECORD_NATURAL_KEY_FIELD];
372
+ const owner = answer[RECORD_OWNER_FIELD];
373
+ return {
374
+ id,
375
+ naturalKey: typeof key === "string" && key !== "" ? key : null,
376
+ ownerId: typeof owner === "string" && owner !== "" ? owner : null,
377
+ fields: values,
378
+ unreadable: [...unreadable]
379
+ };
380
+ }
381
+ return {
382
+ request: (document, variables) => run(document, variables),
383
+ async whoami() {
384
+ const me = await field(
385
+ "whoami",
386
+ `query AppWhoami { whoami { credential { app capabilities expiresAt modelConfinement { read write delete publish } } authority { effective } } }`
387
+ );
388
+ const models = me.credential?.modelConfinement;
389
+ return {
390
+ app: me.credential?.app ?? null,
391
+ capabilities: me.credential?.capabilities ?? [],
392
+ models: {
393
+ read: models?.read ?? [],
394
+ write: models?.write ?? [],
395
+ delete: models?.delete ?? [],
396
+ publish: models?.publish ?? []
397
+ },
398
+ effective: me.authority?.effective ?? [],
399
+ expiresAt: me.credential?.expiresAt ?? null
400
+ };
401
+ },
402
+ install: {
403
+ async config() {
404
+ const raw = await field(
405
+ "integrationConfig",
406
+ `query AppInstallConfig {
407
+ integrationConfig {
408
+ name enabled middlewareUrl targetModels webhookUrl config
409
+ sources { key model naturalKey mode fields { appField modelField direction } }
410
+ sinks { key model naturalKey fields { appField modelField direction } }
411
+ }
412
+ }`
413
+ );
414
+ if (!raw) refuse("the platform answered the install configuration with nothing at all");
415
+ const got = raw;
416
+ return {
417
+ name: got.name ?? "",
418
+ enabled: got.enabled === true,
419
+ middlewareUrl: got.middlewareUrl ?? "",
420
+ targetModels: got.targetModels ?? [],
421
+ webhookUrl: got.webhookUrl ?? "",
422
+ settings: got.config ?? {},
423
+ sources: bindingsOf(got.sources),
424
+ sinks: bindingsOf(got.sinks)
425
+ };
426
+ },
427
+ async credentials() {
428
+ const answer = await field("appCredential", "query AppCredential { appCredential { value } }");
429
+ const decoded = decodeBase64Utf8(answer?.value ?? "");
430
+ let parsed;
431
+ try {
432
+ parsed = JSON.parse(decoded || "{}");
433
+ } catch {
434
+ return refuse("the install credential is not the JSON object of fields the platform stores");
435
+ }
436
+ const out = {};
437
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
438
+ for (const [key, value] of Object.entries(parsed)) {
439
+ if (typeof value === "string") out[key] = value;
440
+ }
441
+ }
442
+ return out;
443
+ },
444
+ async writeCredentials(values) {
445
+ const scalars = {};
446
+ for (const [key, value] of Object.entries(values)) {
447
+ if (typeof value === "string" && value !== "") scalars[key] = value;
448
+ }
449
+ if (Object.keys(scalars).length === 0) {
450
+ refuse("a credential write needs at least one non-empty field; the platform refuses an empty one");
451
+ }
452
+ await field(
453
+ "writeAppCredential",
454
+ "mutation AppWriteCredential($value: String!) { writeAppCredential(value: $value) }",
455
+ { value: encodeBase64Utf8(JSON.stringify(scalars)) }
456
+ );
457
+ },
458
+ async reportStatus(status) {
459
+ const input = {};
460
+ for (const [key, value] of Object.entries(status)) {
461
+ if (value === void 0) continue;
462
+ if ((key === "lastSyncAt" || key === "lastErrorAt") && typeof value === "string" && !Number.isFinite(Date.parse(value))) {
463
+ refuse(`${key} must be an RFC 3339 time`);
464
+ }
465
+ input[key] = value;
466
+ }
467
+ if (Object.keys(input).length === 0) return;
468
+ await field(
469
+ "reportIntegrationStatus",
470
+ "mutation AppReportStatus($status: IntegrationStatusInput!) { reportIntegrationStatus(status: $status) }",
471
+ { status: input }
472
+ );
473
+ }
474
+ },
475
+ records: {
476
+ async upsert(input) {
477
+ const { model, naturalKey, data, ownerId, sourceModifiedAt } = input;
478
+ if (naturalKey === "") refuse(`an upsert on ${model} was given an empty natural key, which would collide every row onto one record`);
479
+ if (ownerId === "") refuse(`an upsert on ${model} named an empty owner`);
480
+ const params = ["$modelKey: String!", "$naturalKey: String!", "$data: JSON!"];
481
+ const args = ["modelKey: $modelKey", "naturalKey: $naturalKey", "data: $data"];
482
+ const variables = { modelKey: model, naturalKey, data };
483
+ if (ownerId !== void 0) {
484
+ params.push("$ownerId: ID");
485
+ args.push("ownerId: $ownerId");
486
+ variables.ownerId = ownerId;
487
+ }
488
+ const timed = sourceModifiedAt !== void 0;
489
+ if (timed) {
490
+ params.push("$sourceModifiedAt: String");
491
+ args.push("sourceModifiedAt: $sourceModifiedAt");
492
+ variables.sourceModifiedAt = sourceModifiedAt;
493
+ }
494
+ const answer = await field(
495
+ "upsertRecord",
496
+ `mutation AppUpsertRecord(${params.join(", ")}) { upsertRecord(${args.join(", ")}) { record created${timed ? " stale" : ""} } }`,
497
+ variables
498
+ );
499
+ const record = answer?.record;
500
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
501
+ return refuse(`upsertRecord reported no error and returned no record for "${naturalKey}" on ${model}`);
502
+ }
503
+ const written = firstString(record, ["naturalKey", "natural_key"]);
504
+ if (written !== null && written !== naturalKey) {
505
+ refuse(`upsertRecord was sent the natural key "${naturalKey}" for ${model} and answered with "${written}"`);
506
+ }
507
+ return {
508
+ record,
509
+ id: recordIdOf(record),
510
+ created: answer?.created === true,
511
+ stale: answer?.stale === true
512
+ };
513
+ },
514
+ read: readOne,
515
+ async delete(model, id) {
516
+ if (id === "") refuse(`a record delete on ${model} was given an empty id`);
517
+ const name = `delete${appModelTypeName(model)}`;
518
+ const deleted = await field(name, `mutation AppDeleteRecord($id: String!) { ${name}(id: $id) }`, { id });
519
+ return { deleted: deleted === true };
520
+ },
521
+ async refresh(model, idOrKey) {
522
+ if (idOrKey === "") refuse(`a record refresh on ${model} was given an empty name`);
523
+ const name = `refresh${appModelTypeName(model)}`;
524
+ const refreshed = await field(name, `mutation AppRefreshRecord($idOrKey: String!) { ${name}(idOrKey: $idOrKey) }`, { idOrKey });
525
+ if (typeof refreshed !== "boolean") return refuse(`${name} reported no error and did not say whether it refreshed the record`);
526
+ return { refreshed };
527
+ },
528
+ async rename(model, where, naturalKey) {
529
+ if (Object.keys(where).length === 0) refuse(`a rename on ${model} named no filter, so it names no one record`);
530
+ if (naturalKey === "") refuse(`a rename on ${model} was given an empty key`);
531
+ const type = appModelTypeName(model);
532
+ const name = `rename${type}`;
533
+ await field(
534
+ name,
535
+ `mutation AppRenameRecord($where: ${type}WhereInput!, $naturalKey: String!) { ${name}(where: $where, naturalKey: $naturalKey) { replayed } }`,
536
+ { where, naturalKey }
537
+ );
538
+ },
539
+ async updateMany(model, input) {
540
+ if (Object.keys(input.where).length === 0) refuse(`an update on ${model} named no filter, which would change every record`);
541
+ if (Object.keys(input.data).length === 0) refuse(`an update on ${model} named nothing to change`);
542
+ const type = appModelTypeName(model);
543
+ const name = `updateMany${type}`;
544
+ const answer = await field(
545
+ name,
546
+ `mutation AppUpdateRecords($where: ${type}WhereInput, $data: ${type}UpdateInput!, $idempotencyKey: String) { ${name}(where: $where, data: $data, idempotencyKey: $idempotencyKey) { count ids } }`,
547
+ { where: input.where, data: input.data, ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {} }
548
+ );
549
+ if (!answer || typeof answer.count !== "number") {
550
+ return refuse(`${name} reported no error and did not say how many records it updated`);
551
+ }
552
+ return { count: answer.count, ids: Array.isArray(answer.ids) ? answer.ids : [] };
553
+ },
554
+ async aggregate(model, query) {
555
+ if (query.groupBy === "") refuse(`a count on ${model} was grouped by an empty field`);
556
+ const type = appModelTypeName(model);
557
+ const name = `${appModelFieldName(model)}Aggregate`;
558
+ const answer = await field(
559
+ name,
560
+ `query AppAggregateRecords($where: ${type}WhereInput, $groupBy: ${type}AggregatableField, $limit: Int) { ${name}(where: $where, groupBy: $groupBy, limit: $limit) { groups { key count } truncated } }`,
561
+ {
562
+ groupBy: appSelectorEnumValue(query.groupBy),
563
+ ...query.where && Object.keys(query.where).length > 0 ? { where: query.where } : {},
564
+ ...query.limit === void 0 ? {} : { limit: query.limit }
565
+ }
566
+ );
567
+ if (!answer || !Array.isArray(answer.groups)) return refuse(`${name} reported no error and returned no groups`);
568
+ return {
569
+ groups: answer.groups.map((group) => ({
570
+ key: typeof group.key === "string" ? group.key : null,
571
+ count: typeof group.count === "number" ? group.count : 0
572
+ })),
573
+ truncated: answer.truncated === true
574
+ };
575
+ },
576
+ customer: {
577
+ async save(input) {
578
+ const { model, identity, data, email, sourceModifiedAt } = input;
579
+ if (identity === "") refuse(`a customer's record on ${model} was saved with no identity`);
580
+ const variables = { modelKey: model, identity, data };
581
+ if (email !== void 0 && email !== "") variables.email = email;
582
+ const timed = sourceModifiedAt !== void 0;
583
+ if (timed) variables.sourceModifiedAt = sourceModifiedAt;
584
+ let answer;
585
+ try {
586
+ answer = await field(
587
+ "saveCustomerRecord",
588
+ timed ? `mutation AppSaveCustomerRecord($modelKey: String!, $identity: String!, $email: String, $data: JSON!, $sourceModifiedAt: String) { saveCustomerRecord(modelKey: $modelKey, identity: $identity, email: $email, data: $data, sourceModifiedAt: $sourceModifiedAt) { record created customerCreated stale } }` : `mutation AppSaveCustomerRecord($modelKey: String!, $identity: String!, $email: String, $data: JSON!) { saveCustomerRecord(modelKey: $modelKey, identity: $identity, email: $email, data: $data) { record created customerCreated } }`,
589
+ variables
590
+ );
591
+ } catch (error) {
592
+ if (appCustomerUnlinkedOf(error)) return { unlinked: true };
593
+ throw error;
594
+ }
595
+ const record = answer?.record;
596
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
597
+ return refuse(`saveCustomerRecord reported no error and returned no record on ${model}`);
598
+ }
599
+ return {
600
+ unlinked: false,
601
+ record,
602
+ id: recordIdOf(record),
603
+ created: answer?.created === true,
604
+ customerCreated: answer?.customerCreated === true,
605
+ stale: answer?.stale === true
606
+ };
607
+ },
608
+ async read(model, identity) {
609
+ if (identity === "") refuse(`a customer's record on ${model} was read with no identity`);
610
+ const record = await field(
611
+ "customerRecordByIdentity",
612
+ `query AppCustomerRecord($modelKey: String!, $identity: String!) { customerRecordByIdentity(modelKey: $modelKey, identity: $identity) }`,
613
+ { modelKey: model, identity }
614
+ );
615
+ if (!record) return null;
616
+ const id = recordIdOf(record);
617
+ if (!id) return refuse(`customerRecordByIdentity answered a record with no id on ${model}`);
618
+ const data = record.data;
619
+ return {
620
+ id,
621
+ naturalKey: firstString(record, ["naturalKey", "natural_key"]),
622
+ ownerId: typeof record.ownerId === "string" && record.ownerId !== "" ? record.ownerId : null,
623
+ fields: data && typeof data === "object" && !Array.isArray(data) ? data : {},
624
+ unreadable: []
625
+ };
626
+ },
627
+ async erase(model, identity) {
628
+ if (identity === "") refuse(`a customer's record on ${model} was erased with no identity`);
629
+ const answer = await field(
630
+ "eraseCustomerRecord",
631
+ `mutation AppEraseCustomerRecord($modelKey: String!, $identity: String!) { eraseCustomerRecord(modelKey: $modelKey, identity: $identity) { recordDeleted identityRemoved customerDeleted customerKeptBecause } }`,
632
+ { modelKey: model, identity }
633
+ );
634
+ if (!answer) return refuse(`eraseCustomerRecord reported no error and returned no answer on ${model}`);
635
+ return {
636
+ recordDeleted: answer.recordDeleted === true,
637
+ identityRemoved: answer.identityRemoved === true,
638
+ customerDeleted: answer.customerDeleted === true,
639
+ customerKeptBecause: answer.customerKeptBecause ?? null
640
+ };
641
+ },
642
+ async identities(model, customerId) {
643
+ if (customerId === "") refuse(`a customer's identities on ${model} were asked for with no customer`);
644
+ const answer = await field(
645
+ "customerIdentitiesById",
646
+ `query AppCustomerIdentities($modelKey: String!, $customerId: ID!) { customerIdentitiesById(modelKey: $modelKey, customerId: $customerId) }`,
647
+ { modelKey: model, customerId }
648
+ );
649
+ if (!Array.isArray(answer)) return refuse(`customerIdentitiesById reported no error and returned no list on ${model}`);
650
+ return answer.filter((identity) => typeof identity === "string");
651
+ }
652
+ }
653
+ },
654
+ customers: {
655
+ async ensure(email) {
656
+ const address = email.trim();
657
+ if (address === "") refuse("a customer was asked for with an empty email");
658
+ const customer = await field(
659
+ "createCustomer",
660
+ `mutation AppEnsureCustomer($input: CreateCustomerInput!) { createCustomer(input: $input) { id email } }`,
661
+ { input: { email: address, returnExisting: true } }
662
+ );
663
+ if (!customer?.id) return refuse("createCustomer reported no error and returned no customer");
664
+ return { id: customer.id, email: customer.email ?? address };
665
+ }
666
+ },
667
+ notifications: {
668
+ async send(notification) {
669
+ if (notification.customerId === "" || notification.title.trim() === "") {
670
+ refuse("a notification needs a customer and a title");
671
+ }
672
+ const input = { customerId: notification.customerId, title: notification.title };
673
+ if (notification.message !== void 0) input.message = notification.message;
674
+ if (notification.category !== void 0) input.category = notification.category;
675
+ if (notification.actionUrl !== void 0) input.actionUrl = notification.actionUrl;
676
+ if (notification.imageUrl !== void 0) input.imageUrl = notification.imageUrl;
677
+ if (notification.idempotencyKey !== void 0) input.idempotencyKey = notification.idempotencyKey;
678
+ if (notification.email) {
679
+ const email = { body: notification.email.body };
680
+ if (notification.email.subject !== void 0) email.subject = notification.email.subject;
681
+ if (notification.email.ctas?.length) email.ctas = notification.email.ctas.map((cta) => ({ label: cta.label, url: cta.url }));
682
+ input.channels = { email };
683
+ }
684
+ const sent = await field(
685
+ "sendNotification",
686
+ `mutation AppSendNotification($input: SendNotificationInput!) { sendNotification(input: $input) }`,
687
+ { input }
688
+ );
689
+ if (sent !== true) refuse("sendNotification reported no error and did not say the notification was sent");
690
+ },
691
+ async setConsent(change) {
692
+ if (change.customerId === "" || change.category === "") refuse("a consent change needs a customer and a category");
693
+ const input = {
694
+ customerId: change.customerId,
695
+ category: change.category,
696
+ channel: change.channel,
697
+ enabled: change.enabled
698
+ };
699
+ if (change.sourceModifiedAt !== void 0) input.sourceModifiedAt = change.sourceModifiedAt;
700
+ const answer = await field(
701
+ "setCustomerConsent",
702
+ `mutation AppSetCustomerConsent($input: SetCustomerConsentInput!) { setCustomerConsent(input: $input) { stale } }`,
703
+ { input }
704
+ );
705
+ if (!answer) return refuse("setCustomerConsent reported no error and answered nothing");
706
+ return { stale: answer.stale === true };
707
+ }
708
+ }
709
+ };
710
+ }
711
+ function directionOf(raw) {
712
+ const name = (raw ?? "").replace(/^FIELD_SYNC_DIRECTION_/, "");
713
+ return name === "SOURCE_ONLY" || name === "BIDIRECTIONAL" ? name : "SINK_ONLY";
714
+ }
715
+ function bindingsOf(raw) {
716
+ const out = [];
717
+ for (const binding of raw ?? []) {
718
+ if (!binding?.key || !binding.model) continue;
719
+ const fields = [];
720
+ for (const mapped of binding.fields ?? []) {
721
+ if (!mapped?.appField || !mapped.modelField) continue;
722
+ fields.push({ appField: mapped.appField, modelField: mapped.modelField, direction: directionOf(mapped.direction) });
723
+ }
724
+ out.push({
725
+ key: binding.key,
726
+ model: binding.model,
727
+ naturalKey: binding.naturalKey ?? "",
728
+ fields,
729
+ mode: binding.mode === "REFERENCE" ? "REFERENCE" : "SYNC"
730
+ });
731
+ }
732
+ return out;
733
+ }
734
+ function firstString(record, keys) {
735
+ for (const key of keys) {
736
+ const value = record[key];
737
+ if (typeof value === "string" && value !== "") return value;
738
+ }
739
+ return null;
740
+ }
741
+ function recordIdOf(record) {
742
+ return firstString(record, ["id", "_id", "recordId", "record_id"]);
743
+ }
744
+ function decodeBase64Utf8(value) {
745
+ const binary = atob(value);
746
+ const bytes = new Uint8Array(binary.length);
747
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
748
+ return new TextDecoder().decode(bytes);
749
+ }
750
+ function encodeBase64Utf8(value) {
751
+ const bytes = new TextEncoder().encode(value);
752
+ let binary = "";
753
+ for (const byte of bytes) binary += String.fromCharCode(byte);
754
+ return btoa(binary);
755
+ }
756
+
757
+ // src/app/record-key.ts
758
+ var RECORD_KEY_RULE = `a record's key is one URL-safe segment: letters, digits, "-", "_", "." and "~", and never "." or ".."`;
759
+ var RECORD_KEY_PATTERN = /^[A-Za-z0-9._~-]+$/;
760
+ function recordKeyRefusal(key) {
761
+ if (RECORD_KEY_PATTERN.test(key) && key !== "." && key !== "..") return null;
762
+ return `record key ${JSON.stringify(key)} is refused: ${RECORD_KEY_RULE}`;
763
+ }
764
+
765
+ // src/app/dispatch.ts
766
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
767
+ var text = (value) => typeof value === "string" && value !== "" ? value : void 0;
768
+ function readAppDispatch(body) {
769
+ const raw = isObject(body) ? body : {};
770
+ const trigger = isObject(raw.trigger) ? raw.trigger : {};
771
+ const executionId = text(raw.executionId) ?? text(raw.execution_id);
772
+ const operationKey = text(raw.operationKey) ?? text(raw.operation);
773
+ const triggeredBy = text(raw.triggered_by) ?? text(trigger.type);
774
+ const callback = readCallback(raw.callback);
775
+ const continuation = readContinuation(raw.continuation);
776
+ return {
777
+ ...executionId === void 0 ? {} : { executionId },
778
+ ...operationKey === void 0 ? {} : { operationKey },
779
+ ...triggeredBy === void 0 ? {} : { triggeredBy },
780
+ input: raw.input ?? null,
781
+ ...callback === void 0 ? {} : { callback },
782
+ ...continuation === void 0 ? {} : { continuation },
783
+ raw
784
+ };
785
+ }
786
+ function readCallback(value) {
787
+ if (!isObject(value)) return void 0;
788
+ const token = text(value.token);
789
+ if (!token) return void 0;
790
+ const mutations = isObject(value.mutations) ? value.mutations : {};
791
+ const signingKey = text(value.signingKey);
792
+ return {
793
+ gqlEndpoint: text(value.gqlEndpoint) ?? "",
794
+ token,
795
+ expiresAt: text(value.expiresAt) ?? "",
796
+ mutations: {
797
+ complete: text(mutations.complete) ?? "",
798
+ fail: text(mutations.fail) ?? "",
799
+ progress: text(mutations.progress) ?? "",
800
+ cancel: text(mutations.cancel) ?? ""
801
+ },
802
+ ...Array.isArray(value.causationChain) ? { causationChain: value.causationChain.map(String) } : {},
803
+ ...signingKey ? { signingKey } : {}
804
+ };
805
+ }
806
+ function readContinuation(value) {
807
+ if (!isObject(value)) return void 0;
808
+ const cursor = text(value.cursor);
809
+ if (!cursor) return void 0;
810
+ return { cursor, slice: typeof value.slice === "number" && Number.isInteger(value.slice) ? value.slice : 0 };
811
+ }
812
+ function readCredentialProbe(body) {
813
+ const raw = isObject(body) ? body : {};
814
+ const submitted = isObject(raw.values) ? raw.values : {};
815
+ const values = {};
816
+ for (const [key, value] of Object.entries(submitted)) {
817
+ if (value === null || value === void 0 || typeof value === "object") continue;
818
+ values[key] = String(value);
819
+ }
820
+ return { values };
821
+ }
822
+
823
+ // src/app/ticket.ts
824
+ async function redeemExchangeTicket(options) {
825
+ if (!options.ticket) throw new FoirRequestError("[foir] redeemExchangeTicket needs the ticket");
826
+ const doFetch = options.fetch ?? globalThis.fetch;
827
+ let res;
828
+ try {
829
+ res = await doFetch(foirApiEndpoint(options.apiUrl, "/auth/exchange-ticket"), {
830
+ ...options.requestInit,
831
+ method: "POST",
832
+ headers: { "content-type": "application/json" },
833
+ body: JSON.stringify({ ticket: options.ticket })
834
+ });
835
+ } catch (err) {
836
+ throw new FoirRequestError("[foir] the exchange ticket could not be redeemed: no answer", { cause: err });
837
+ }
838
+ if (!res.ok) {
839
+ let detail = "";
840
+ try {
841
+ detail = (await res.text()).slice(0, 300);
842
+ } catch {
843
+ }
844
+ throw new FoirRequestError(`[foir] the exchange ticket was refused: HTTP ${res.status}${detail ? ` ${detail}` : ""}`, {
845
+ status: res.status
846
+ });
847
+ }
848
+ const body = await res.json();
849
+ if (typeof body.token !== "string" || body.token === "") {
850
+ throw new FoirRequestError("[foir] the exchange ticket was redeemed for nothing: the answer carried no token", {
851
+ status: res.status
852
+ });
853
+ }
854
+ const token = body.token;
855
+ return {
856
+ dispatch: { token: () => token, context: null },
857
+ expiresAt: typeof body.expires_at === "string" ? body.expires_at : ""
858
+ };
859
+ }
860
+
861
+ // src/app/manifest.ts
862
+ var MAX_APP_MANIFEST_BYTES = 256 * 1024;
863
+ async function validateAppManifest(options) {
864
+ const manifestJson = typeof options.manifest === "string" ? options.manifest : JSON.stringify(options.manifest);
865
+ if (new TextEncoder().encode(manifestJson).length > MAX_APP_MANIFEST_BYTES) {
866
+ throw new FoirRequestError(`[foir] a manifest is at most ${MAX_APP_MANIFEST_BYTES} bytes`);
867
+ }
868
+ let headers;
869
+ if (options.dispatch !== void 0) {
870
+ const token = (await options.dispatch.token())?.trim();
871
+ if (!token) throw new FoirRequestError("[foir] validateAppManifest: the dispatch token provider returned no token");
872
+ headers = buildFoirHeaders({ dispatchToken: token, dispatchContext: options.dispatch.context ?? void 0 });
873
+ } else {
874
+ if (!options.apiKey) throw new FoirRequestError("[foir] validateAppManifest needs a credential: `apiKey` or `dispatch`");
875
+ headers = buildFoirHeaders({ apiKey: options.apiKey });
876
+ }
877
+ const doFetch = options.fetch ?? globalThis.fetch;
878
+ let res;
879
+ try {
880
+ res = await doFetch(foirApiEndpoint(options.apiUrl, "/graphql"), {
881
+ ...options.requestInit,
882
+ method: "POST",
883
+ headers,
884
+ body: JSON.stringify({
885
+ query: "query ValidateAppManifest($manifestJson: String!) { validateAppManifest(manifestJson: $manifestJson) { ok issues { severity path message } } }",
886
+ variables: { manifestJson }
887
+ })
888
+ });
889
+ } catch (err) {
890
+ throw new FoirRequestError("[foir] network error", { cause: err });
891
+ }
892
+ let json;
893
+ try {
894
+ json = await res.json();
895
+ } catch {
896
+ throw new FoirRequestError(`[foir] HTTP ${res.status} ${res.statusText}`, { status: res.status });
897
+ }
898
+ if (!res.ok && !json.errors?.length) {
899
+ throw new FoirRequestError(`[foir] HTTP ${res.status} ${res.statusText}`, { status: res.status });
900
+ }
901
+ const answer = readFoirEnvelope(json, { strict: true }).validateAppManifest;
902
+ if (!answer || typeof answer.ok !== "boolean") {
903
+ throw new FoirRequestError("[foir] the platform answered the manifest check with no verdict", { status: res.status });
904
+ }
905
+ return {
906
+ ok: answer.ok,
907
+ issues: (answer.issues ?? []).map((issue) => ({
908
+ // An issue the platform did not grade is not one to wave through.
909
+ severity: issue.severity === "WARNING" ? "WARNING" : "ERROR",
910
+ path: issue.path ?? "",
911
+ message: issue.message ?? ""
912
+ }))
913
+ };
914
+ }
915
+
916
+ // src/app/vocabulary.generated.ts
917
+ var APP_HOOK_EVENTS = [
918
+ "RECORD_CREATED",
919
+ "RECORD_UPDATED",
920
+ "RECORD_DELETED",
921
+ "RECORD_PUBLISHED",
922
+ "RECORD_UNPUBLISHED",
923
+ "CUSTOMER_CREATED",
924
+ "CUSTOMER_UPDATED",
925
+ "CUSTOMER_DELETED",
926
+ "CUSTOMER_MERGED",
927
+ "SESSION_REVOKED",
928
+ "OPERATION_COMPLETED",
929
+ "OPERATION_FAILED",
930
+ "OPERATION_CANCELLED",
931
+ "OPERATION_TIMED_OUT",
932
+ "ADMIN_MEMBERSHIP_GRANTED",
933
+ "ADMIN_MEMBERSHIP_REVOKED",
934
+ "ADMIN_ROLE_CHANGED",
935
+ "TENANT_PLAN_CHANGED",
936
+ "TENANT_SUBSCRIPTION_STATUS_CHANGED",
937
+ "PROJECT_CREATED",
938
+ "PROJECT_ARCHIVED",
939
+ "INVITATION_ACCEPTED",
940
+ "IMPORT_COMPLETED",
941
+ "MIGRATION_APPLIED",
942
+ "BULK_UPDATE_COMPLETED",
943
+ "BULK_PUBLISH_COMPLETED",
944
+ "CUSTOMER_PREFERENCE_CHANGED",
945
+ "ORGANIZATION_CREATED",
946
+ "ORGANIZATION_UPDATED",
947
+ "ORGANIZATION_MOVED",
948
+ "ORGANIZATION_SUSPENDED",
949
+ "ORGANIZATION_RESUMED",
950
+ "ORGANIZATION_DELETION_SCHEDULED",
951
+ "ORGANIZATION_DELETION_CANCELLED",
952
+ "ORGANIZATION_DELETED",
953
+ "ORGANIZATION_MEMBER_ADDED",
954
+ "ORGANIZATION_MEMBER_REMOVED",
955
+ "ORGANIZATION_MEMBER_ROLE_CHANGED"
956
+ ];
957
+ var APP_CAPABILITIES = [
958
+ "config:read",
959
+ "secrets:read.app",
960
+ "secrets:put.app",
961
+ "secrets:list.app",
962
+ "secrets:delete.app",
963
+ "status:write",
964
+ "files:read",
965
+ "files:list",
966
+ "files:write",
967
+ "customers:write",
968
+ "notifications:write",
969
+ "notifications:consent",
970
+ "operations:complete"
971
+ ];
972
+ var APP_RECORD_CAPABILITY_PREFIXES = [
973
+ "records:read:",
974
+ "records:write:",
975
+ "records:delete:",
976
+ "records:publish:",
977
+ "records:attribute:"
978
+ ];
979
+ export {
980
+ APP_CAPABILITIES,
981
+ APP_HOOK_EVENTS,
982
+ APP_RECORD_CAPABILITY_PREFIXES,
983
+ FoirRequestError,
984
+ MAX_APP_MANIFEST_BYTES,
985
+ RECORD_KEY_RULE,
986
+ appCustomerUnlinkedOf,
987
+ appLookupConflictOf,
988
+ appModelFieldName,
989
+ appModelTypeName,
990
+ appSelectorEnumValue,
991
+ createAppClient,
992
+ isAppFieldWithheld,
993
+ readAppDispatch,
994
+ readCredentialProbe,
995
+ recordKeyRefusal,
996
+ redeemExchangeTicket,
997
+ validateAppManifest
998
+ };