@zackbart/connecta 0.40.0 → 0.41.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 (115) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/bin/version.mjs +1 -1
  3. package/dist/activity.d.ts +30 -4
  4. package/dist/activity.js +28 -2
  5. package/dist/agent-observation.js +24 -2
  6. package/dist/agent-routes.js +4 -41
  7. package/dist/auth/clerk.d.ts +6 -1
  8. package/dist/auth/clerk.js +16 -0
  9. package/dist/auth/cloudflare-access.js +49 -7
  10. package/dist/auth/downstream-oauth/consent.js +79 -29
  11. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  12. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  13. package/dist/auth/downstream-oauth.js +12 -4
  14. package/dist/auth/oauth-request-not-sent.js +4 -0
  15. package/dist/auth/static-oauth.js +51 -21
  16. package/dist/catalog-cache.js +62 -21
  17. package/dist/catalog-freshness.js +13 -0
  18. package/dist/catalog-service.js +39 -12
  19. package/dist/catalog.js +103 -38
  20. package/dist/config.d.ts +10 -4
  21. package/dist/config.js +30 -7
  22. package/dist/connectors/remote-mcp/call.js +1 -1
  23. package/dist/connectors/remote-mcp/connection.js +52 -18
  24. package/dist/connectors/remote-mcp/oauth.js +2 -2
  25. package/dist/connectors/remote-mcp/resources.js +5 -2
  26. package/dist/connectors/remote-mcp/transport.js +24 -6
  27. package/dist/connectors/remote-mcp.js +4 -4
  28. package/dist/credential-contract.d.ts +3 -1
  29. package/dist/describe-config.d.ts +1 -0
  30. package/dist/describe-config.js +1 -0
  31. package/dist/downstream-input.js +11 -2
  32. package/dist/errors.d.ts +8 -1
  33. package/dist/errors.js +2 -0
  34. package/dist/execute.js +20 -7
  35. package/dist/executor-result.js +1 -1
  36. package/dist/executors/quickjs-child.js +1 -1
  37. package/dist/executors/quickjs-runtime.js +1 -1
  38. package/dist/executors/quickjs.js +1 -1
  39. package/dist/guarded-transport.js +2 -0
  40. package/dist/http/client.js +7 -0
  41. package/dist/http/hatch.js +2 -0
  42. package/dist/http/node-transport.js +110 -4
  43. package/dist/http/oauth.js +17 -6
  44. package/dist/index.js +10 -21
  45. package/dist/invocation-failure.js +28 -0
  46. package/dist/invocation.js +72 -33
  47. package/dist/membership.d.ts +1 -1
  48. package/dist/meta-tools.js +27 -13
  49. package/dist/module-contracts.d.ts +3 -1
  50. package/dist/native-tools.js +11 -4
  51. package/dist/node.d.ts +7 -0
  52. package/dist/node.js +82 -3
  53. package/dist/operation-activity.js +103 -0
  54. package/dist/operator-record.d.ts +8 -0
  55. package/dist/operator-record.js +15 -0
  56. package/dist/operator-ui/generated.js +2 -2
  57. package/dist/provider.d.ts +1 -1
  58. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  59. package/dist/providers/breeze/index.js +1 -1
  60. package/dist/providers/ccb/index.js +1 -1
  61. package/dist/providers/cloudflare/index.js +1 -1
  62. package/dist/providers/docs/index.js +1 -1
  63. package/dist/providers/drive/index.js +1 -1
  64. package/dist/providers/forms/index.js +1 -1
  65. package/dist/providers/github/index.js +1 -1
  66. package/dist/providers/gmail/index.js +1 -1
  67. package/dist/providers/granola/index.js +1 -1
  68. package/dist/providers/overflow/index.js +1 -1
  69. package/dist/providers/planning-center/index.js +1 -1
  70. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  71. package/dist/providers/quickbooks/api.js +28 -25
  72. package/dist/providers/quickbooks/index.js +1 -1
  73. package/dist/providers/quickbooks/lib/writes.js +55 -0
  74. package/dist/providers/quickbooks/safety.js +17 -5
  75. package/dist/providers/quickbooks/skill.generated.js +1 -1
  76. package/dist/providers/sheets/index.js +1 -1
  77. package/dist/providers/slides/index.js +1 -1
  78. package/dist/providers/stripe/index.js +2 -2
  79. package/dist/providers/tithely/index.js +1 -1
  80. package/dist/providers/vercel/api.js +1 -0
  81. package/dist/registry.js +16 -4
  82. package/dist/resource-read.js +21 -2
  83. package/dist/route-names.js +44 -0
  84. package/dist/routes/activity-context.js +1 -0
  85. package/dist/routes/activity-disclosure.js +26 -4
  86. package/dist/routes/activity.js +2 -1
  87. package/dist/routes/mcp.js +32 -13
  88. package/dist/routes/ui-config.js +1 -1
  89. package/dist/routes/ui-data.js +3 -2
  90. package/dist/runtime/native-storage.js +12 -0
  91. package/dist/runtime/request-reads.js +66 -14
  92. package/dist/runtime/request-scope.js +2 -1
  93. package/dist/skill-origin.js +16 -0
  94. package/dist/skills.js +132 -43
  95. package/dist/storage/d1-driver.js +33 -5
  96. package/dist/storage/keys.js +7 -1
  97. package/dist/storage/sql-native.js +60 -3
  98. package/dist/storage/sql.js +6 -0
  99. package/dist/timing-trace.js +84 -0
  100. package/dist/types.d.ts +5 -0
  101. package/dist/url-safety.js +38 -20
  102. package/dist/utility-recovery.js +33 -0
  103. package/dist/version.d.ts +1 -1
  104. package/dist/version.js +1 -1
  105. package/dist/worker.js +33 -8
  106. package/documentation/architecture.md +48 -1
  107. package/documentation/auth.md +116 -36
  108. package/documentation/code-mode.md +61 -46
  109. package/documentation/deploying.md +25 -1
  110. package/documentation/meta-tools.md +113 -11
  111. package/documentation/operating.md +86 -1
  112. package/documentation/operator-ui.md +51 -2
  113. package/documentation/providers.md +16 -14
  114. package/package.json +10 -8
  115. package/templates/node/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { resolvedConnectorGuides } from "./skills.js";
1
2
  import { bytesToHex } from "./bytes.js";
2
3
  import { routeNames } from "./agent-routes.js";
3
4
  import { duringViewEffect } from "./runtime/call.js";
@@ -129,10 +130,12 @@ function passThroughSchema(schema) {
129
130
  },
130
131
  };
131
132
  }
132
- function describe(entry, executeCode) {
133
+ function describe(entry, executeCode, guideName) {
133
134
  const lines = [entry.tool.description?.trim() || entry.tool.title || entry.tool.name];
134
135
  if (executeCode)
135
136
  lines.push(`Program address: ${entry.connector.id}.${entry.tool.name}`);
137
+ if (guideName)
138
+ lines.push(`Guide: skills ${guideName}`);
136
139
  return lines.join("\n\n");
137
140
  }
138
141
  export class NativeSurface {
@@ -177,10 +180,10 @@ export class NativeSurface {
177
180
  call(name, args, request) {
178
181
  return runEdge(authOwnMethod(NativeSurface.prototype, this, "call", [name, args, request]));
179
182
  }
180
- registerTool(server, name, entry) {
183
+ registerTool(server, name, entry, guideName) {
181
184
  server.registerTool(name, {
182
185
  ...(entry?.tool.title ? { title: entry.tool.title } : {}),
183
- ...(entry ? { description: describe(entry, this.options.executeCode) } : {}),
186
+ ...(entry ? { description: describe(entry, this.options.executeCode, guideName) } : {}),
184
187
  inputSchema: passThroughSchema(entry?.tool.inputSchema),
185
188
  ...(entry ? { annotations: annotationsFor(entry.tool) } : {}),
186
189
  ...(entry ? { _meta: { "dev.connecta/address": `${entry.connector.id}.${entry.tool.name}` } } : {}),
@@ -220,8 +223,12 @@ export class NativeSurface {
220
223
  });
221
224
  return;
222
225
  }
226
+ const guides = yield* upwardStep(authCall(resolvedConnectorGuides, [
227
+ self.registry.listConnectors(),
228
+ ]));
229
+ const guideNames = new Map(guides.map(({ connector, reference }) => [connector.id, reference]));
223
230
  for (const entry of tools)
224
- self.registerTool(server, entry.name, entry);
231
+ self.registerTool(server, entry.name, entry, guideNames.get(entry.connector.id));
225
232
  })).pipe(Effect.provideService(RequestScope, requestOwner(this.options.requestScope)));
226
233
  }, NativeSurface.prototype.register);
227
234
  registerAuthMethod(NativeSurface.prototype, "call", function (name, args, request) {
package/dist/node.d.ts CHANGED
@@ -8,6 +8,13 @@ export interface ListenOptions {
8
8
  host?: string;
9
9
  /** Pre-auth buffer cap, independent of the authenticated MCP cap. Default 4 MiB. */
10
10
  maxBodyBytes?: number;
11
+ /**
12
+ * Cap on the body bytes all in-flight requests hold together, counted as
13
+ * each arrives and released when its response finishes or its client leaves.
14
+ * Past it a request is answered 503 with `Retry-After`. Default 64 MiB, or
15
+ * `maxBodyBytes` when larger. May not be below `maxBodyBytes`.
16
+ */
17
+ maxInFlightBodyBytes?: number;
11
18
  /**
12
19
  * Stop accepting connections on SIGTERM/SIGINT, let in-flight requests
13
20
  * finish, drain deferred work, then exit. Default true — Docker sends
package/dist/node.js CHANGED
@@ -5,13 +5,40 @@ import { failureRecord, logFailure } from "./operator-record.js";
5
5
  import { configureGuardedTransport } from "./guarded-transport.js";
6
6
  import { createNodeGuardedTransport } from "./http/node-transport.js";
7
7
  import { CONFIG_DEFAULTS } from "./config-defaults.js";
8
+ import { ConfigError } from "./config-schema.js";
8
9
  /** The adapter buffers before auth, bounded by the same default as the core. */
9
10
  const DEFAULT_MAX_BODY_BYTES = CONFIG_DEFAULTS.admission.requests.maxBodyBytes;
11
+ /** Sixteen full default bodies; well above ordinary MCP traffic, whose bodies are small. */
12
+ const DEFAULT_MAX_IN_FLIGHT_BODY_BYTES = 64 * 1024 * 1024;
10
13
  const BODY_TOO_LARGE = Symbol("body-too-large");
11
- async function toRequest(req, maxBodyBytes, signal) {
14
+ const BODIES_AT_CAPACITY = Symbol("bodies-at-capacity");
15
+ /** A name, IPv4 or bracketed IPv6 host with an optional port, and nothing else. */
16
+ const REQUEST_HOST = /^(?:[a-z0-9._-]+|\[[0-9a-f:.]+\])(?::\d{1,5})?$/i;
17
+ /** `Host`, `X-Forwarded-Proto` or the request target is not a URL part; answered 400. */
18
+ class InvalidRequestUrl extends Error {
19
+ }
20
+ /**
21
+ * The request's URL, read from values a client chooses. Each is checked on its
22
+ * own and the origin is parsed before the target joins it, so no value can move
23
+ * the boundary between authority, path and query for the routes behind it.
24
+ */
25
+ function requestUrl(req) {
12
26
  const host = req.headers.host ?? "localhost";
13
27
  const proto = req.headers["x-forwarded-proto"] ?? "http";
14
- const url = `${proto}://${host}${req.url ?? "/"}`;
28
+ // Origin-form only. Absolute-form and `*` have no path to route.
29
+ const target = req.url ?? "/";
30
+ if (!REQUEST_HOST.test(host) || typeof proto !== "string" || !/^https?$/i.test(proto) || !target.startsWith("/")) {
31
+ throw new InvalidRequestUrl();
32
+ }
33
+ try {
34
+ return `${new URL(`${proto}://${host}`).origin}${target}`;
35
+ }
36
+ catch {
37
+ throw new InvalidRequestUrl();
38
+ }
39
+ }
40
+ async function toRequest(req, maxBodyBytes, holdBodyBytes, signal) {
41
+ const url = requestUrl(req);
15
42
  const method = req.method ?? "GET";
16
43
  const headers = new Headers();
17
44
  for (const [key, value] of Object.entries(req.headers)) {
@@ -29,6 +56,8 @@ async function toRequest(req, maxBodyBytes, signal) {
29
56
  // rest of a body we have already decided to reject.
30
57
  if (size > maxBodyBytes)
31
58
  return BODY_TOO_LARGE;
59
+ if (!holdBodyBytes(chunk.length))
60
+ return BODIES_AT_CAPACITY;
32
61
  chunks.push(chunk);
33
62
  }
34
63
  if (chunks.length)
@@ -64,6 +93,13 @@ export function listen(connecta, portOrOptions) {
64
93
  const opts = typeof portOrOptions === "number" ? { port: portOrOptions } : portOrOptions;
65
94
  configureGuardedTransport(createNodeGuardedTransport(opts.proxy));
66
95
  const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
96
+ const maxInFlightBodyBytes = opts.maxInFlightBodyBytes === undefined
97
+ ? Math.max(DEFAULT_MAX_IN_FLIGHT_BODY_BYTES, maxBodyBytes)
98
+ : opts.maxInFlightBodyBytes;
99
+ if (!Number.isSafeInteger(maxInFlightBodyBytes) || maxInFlightBodyBytes < 1 || maxInFlightBodyBytes < maxBodyBytes) {
100
+ throw new ConfigError("listen: maxInFlightBodyBytes must be a whole number of bytes, at least maxBodyBytes.");
101
+ }
102
+ let inFlightBodyBytes = 0;
67
103
  // Node has no ExecutionContext, so deferred work (activity sinks) would
68
104
  // otherwise be an untracked floating promise. Track it here and drain it on
69
105
  // shutdown so both runtimes behave the same.
@@ -101,7 +137,19 @@ export function listen(connecta, portOrOptions) {
101
137
  res.once("close", finished);
102
138
  void (async () => {
103
139
  const controller = new AbortController();
140
+ let heldBodyBytes = 0;
141
+ let bodyBytesReleased = false;
142
+ // Pipeline teardown can wait forever on a response stream's cancel().
143
+ // Release on the HTTP lifecycle too, and forbid late upload reservations.
144
+ const releaseBodyBytes = () => {
145
+ if (bodyBytesReleased)
146
+ return;
147
+ bodyBytesReleased = true;
148
+ inFlightBodyBytes -= heldBodyBytes;
149
+ heldBodyBytes = 0;
150
+ };
104
151
  const abortRequest = () => {
152
+ releaseBodyBytes();
105
153
  if (!controller.signal.aborted) {
106
154
  controller.abort(new Error("HTTP client disconnected."));
107
155
  }
@@ -117,8 +165,28 @@ export function listen(connecta, portOrOptions) {
117
165
  req.once("aborted", abortRequest);
118
166
  req.once("close", abortIncompleteRequest);
119
167
  res.once("close", abortIncompleteResponse);
168
+ res.once("finish", releaseBodyBytes);
169
+ res.once("close", releaseBodyBytes);
170
+ // A queued pipelined response gets neither finish nor close when the
171
+ // socket goes away, and its completed upload's request is already closed.
172
+ socket.once("close", abortRequest);
173
+ const holdBodyBytes = (bytes) => {
174
+ if (bodyBytesReleased || inFlightBodyBytes + bytes > maxInFlightBodyBytes)
175
+ return false;
176
+ inFlightBodyBytes += bytes;
177
+ heldBodyBytes += bytes;
178
+ return true;
179
+ };
120
180
  try {
121
- const request = await toRequest(req, maxBodyBytes, controller.signal);
181
+ const request = await toRequest(req, maxBodyBytes, holdBodyBytes, controller.signal);
182
+ if (request === BODIES_AT_CAPACITY) {
183
+ res.statusCode = 503;
184
+ res.setHeader("Retry-After", "1");
185
+ // Abandoned mid-body, like the 413 below.
186
+ res.setHeader("Connection", "close");
187
+ res.end("Service Unavailable");
188
+ return;
189
+ }
122
190
  if (request === BODY_TOO_LARGE) {
123
191
  res.statusCode = 413;
124
192
  // The request stream was abandoned mid-body, so this connection
@@ -132,6 +200,13 @@ export function listen(connecta, portOrOptions) {
132
200
  await writeResponse(res, response);
133
201
  }
134
202
  catch (error) {
203
+ if (error instanceof InvalidRequestUrl) {
204
+ res.statusCode = 400;
205
+ // Refused before reading the body, so the connection cannot be reused.
206
+ res.setHeader("Connection", "close");
207
+ res.end("Bad Request");
208
+ return;
209
+ }
135
210
  // A client that deliberately went away can make either the request
136
211
  // handler or response pipeline reject. The request's abort signal is
137
212
  // the authoritative classification: there is no caller left to answer,
@@ -152,6 +227,10 @@ export function listen(connecta, portOrOptions) {
152
227
  res.end("Internal Server Error");
153
228
  }
154
229
  finally {
230
+ releaseBodyBytes();
231
+ res.removeListener("finish", releaseBodyBytes);
232
+ res.removeListener("close", releaseBodyBytes);
233
+ socket.removeListener("close", abortRequest);
155
234
  req.removeListener("aborted", abortRequest);
156
235
  req.removeListener("close", abortIncompleteRequest);
157
236
  res.removeListener("close", abortIncompleteResponse);
@@ -0,0 +1,103 @@
1
+ // The root graph calls the optional recorder, never the /activity implementation.
2
+ import { Cause, Effect, Exit } from "effect";
3
+ import { InvocationFailure } from "./invocation-failure.js";
4
+ import { ExecutorAdmissionError } from "./executor-admission.js";
5
+ import { DiscoveryPolicyError } from "./catalog-service.js";
6
+ import { classificationCode, classifyCallError } from "./errors.js";
7
+ import { usesCallerIdentity } from "./connector-caller.js";
8
+ export function operationActivity(context, registry, name, work, counts) {
9
+ if (!context?.recordOperation)
10
+ return work;
11
+ return Effect.suspend(() => {
12
+ const started = performance.now();
13
+ const connectors = registry.listConnectors();
14
+ // Human operations stay owner-only, including discovery of private catalogs.
15
+ let personal = Boolean(context.principalActor);
16
+ try {
17
+ personal ||= connectors.some((connector) => connector.authScope === "personal" ||
18
+ !connector.staticTools ||
19
+ usesCallerIdentity(connector) ||
20
+ connector.describe?.().auth?.mode === "request");
21
+ }
22
+ catch {
23
+ personal = true;
24
+ }
25
+ return work.pipe(Effect.onExit((exit) => Effect.sync(() => {
26
+ const result = Exit.isSuccess(exit)
27
+ ? exit.value
28
+ : undefined;
29
+ const failed = Exit.isFailure(exit) || result?.isError === true;
30
+ const interrupted = Exit.isFailure(exit) && exit.cause.reasons.some(Cause.isInterruptReason);
31
+ const failure = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined;
32
+ const errorCode = interrupted
33
+ ? "cancelled"
34
+ : classificationCode(Exit.isFailure(exit)
35
+ ? failure instanceof InvocationFailure ||
36
+ failure instanceof ExecutorAdmissionError ||
37
+ failure instanceof DiscoveryPolicyError
38
+ ? failure.code
39
+ : classifyCallError(failure).code
40
+ : result?.structuredContent?.error?.code);
41
+ try {
42
+ // Result pages already measure their payload. Other operations have no
43
+ // reusable measurement: never serialize discovery or media for telemetry.
44
+ const page = name === "result" && Exit.isSuccess(exit)
45
+ ? exit.value
46
+ : undefined;
47
+ const bytes = page?.structuredContent?.bytes ?? page?.bytes;
48
+ const resultBytes = typeof bytes === "number" && Number.isSafeInteger(bytes) && bytes >= 0 ? bytes : undefined;
49
+ context.recordOperation(context, {
50
+ operation: {
51
+ name,
52
+ connectors: connectors.map((connector) => connector.id),
53
+ personal,
54
+ ...(counts ? { hostCalls: counts() } : {}),
55
+ },
56
+ durationMs: performance.now() - started,
57
+ resultBytes,
58
+ outcome: !failed
59
+ ? "success"
60
+ : errorCode === "timeout"
61
+ ? "timeout"
62
+ : errorCode === "cancelled" || errorCode === "executor_cancelled"
63
+ ? "cancelled"
64
+ : "error",
65
+ errorCode,
66
+ });
67
+ }
68
+ catch {
69
+ /* Optional recording cannot replace the operation's result. */
70
+ }
71
+ })));
72
+ });
73
+ }
74
+ const OPERATION_NAMES = new Set(["search", "describe", "result", "skill", "program"]);
75
+ export function checkedActivityOperation(value) {
76
+ if (!value || typeof value !== "object")
77
+ return undefined;
78
+ const input = value;
79
+ if (!OPERATION_NAMES.has(input.name) ||
80
+ typeof input.personal !== "boolean" ||
81
+ !Array.isArray(input.connectors) ||
82
+ input.connectors.some((id) => typeof id !== "string" || !/^[a-zA-Z0-9_-]+$/.test(id)))
83
+ return undefined;
84
+ const counts = input.hostCalls;
85
+ if (counts &&
86
+ [counts.attempted, counts.admitted, counts.succeeded, counts.failed].some((n) => typeof n !== "number" || !Number.isSafeInteger(n) || n < 0))
87
+ return undefined;
88
+ return {
89
+ name: input.name,
90
+ connectors: [...new Set(input.connectors)],
91
+ personal: input.personal,
92
+ ...(counts
93
+ ? {
94
+ hostCalls: {
95
+ attempted: counts.attempted,
96
+ admitted: counts.admitted,
97
+ succeeded: counts.succeeded,
98
+ failed: counts.failed,
99
+ },
100
+ }
101
+ : {}),
102
+ };
103
+ }
@@ -23,8 +23,15 @@ export declare function labelErrorClass(ctor: {
23
23
  * name is exactly one of those, else `DOMException`.
24
24
  */
25
25
  export declare function errorLabel(failure: unknown): string | undefined;
26
+ interface IdentityLookupFact {
27
+ outcome: "timeout" | "rejected";
28
+ durationMs: number;
29
+ cold: boolean;
30
+ inFlight: number;
31
+ }
26
32
  /** Facts connecta derived about a failure where it saw it. */
27
33
  export interface FailureFacts {
34
+ identityLookup?: IdentityLookupFact;
28
35
  /** Inbound refusal classification attached to its Response, never its body. */
29
36
  reason?: AuthDenialReason;
30
37
  step?: FailureStep;
@@ -64,6 +71,7 @@ export interface FailureSubject {
64
71
  declare const recordBrand: unique symbol;
65
72
  /** One failure as an operator may read it. Only `failureRecord` makes one. */
66
73
  export interface FailureRecord {
74
+ readonly identityLookup?: IdentityLookupFact;
67
75
  readonly [recordBrand]: true;
68
76
  readonly reason?: AuthDenialReason;
69
77
  readonly accessContext?: boolean;
@@ -165,6 +165,20 @@ export function errorLabel(failure) {
165
165
  }
166
166
  return undefined;
167
167
  }
168
+ function identityLookupFact(value) {
169
+ if (!value ||
170
+ (value.outcome !== "timeout" && value.outcome !== "rejected") ||
171
+ count(value.durationMs, 86_400_000) === undefined ||
172
+ typeof value.cold !== "boolean" ||
173
+ count(value.inFlight, 1_000) === undefined)
174
+ return undefined;
175
+ return {
176
+ outcome: value.outcome,
177
+ durationMs: Math.round(value.durationMs),
178
+ cold: value.cold,
179
+ inFlight: Math.round(value.inFlight),
180
+ };
181
+ }
168
182
  function origin(value) {
169
183
  if (typeof value !== "string")
170
184
  return undefined;
@@ -190,6 +204,7 @@ function checkedFacts(facts) {
190
204
  return {
191
205
  ...defined("reason", member(facts.reason, AUTH_DENIAL_REASONS)),
192
206
  ...defined("step", member(facts.step, STEPS)),
207
+ ...defined("identityLookup", identityLookupFact(facts.identityLookup)),
193
208
  ...defined("origin", origin(facts.origin)),
194
209
  ...defined("httpStatus", httpStatus(facts.httpStatus)),
195
210
  ...defined("oauthError", member(facts.oauthError, OAUTH_ERROR_CODES)),