zitejs 0.10.0-start.12 → 0.10.0-start.13

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.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * What a call on an org's own AI key used, read from the provider's answer as
3
+ * classic's runtime reads it. For analytics only: the org pays the provider.
4
+ * OpenAI and Anthropic; the host meters Zite's own key itself.
5
+ */
6
+ export type AiProvider = "openai" | "anthropic";
7
+ export type AiUsageRecord = {
8
+ provider: AiProvider;
9
+ model?: string;
10
+ inputTokens?: number;
11
+ outputTokens?: number;
12
+ cachedReadInputTokens?: number;
13
+ cacheCreationInputTokens?: number;
14
+ isStreaming?: true;
15
+ };
16
+ export declare const AI_USAGE_HOSTS: Record<string, AiProvider>;
17
+ /** As classic: an OpenAI chat stream reports its usage only when asked to. The body to send instead, if any. */
18
+ export declare const withStreamUsage: (url: URL, method: string, body: unknown) => string | undefined;
19
+ /** The response to hand the app, and its usage once read; a stream is split so the app reads one copy as it arrives. */
20
+ export declare const meterAiResponse: (provider: AiProvider, response: Response) => {
21
+ response: Response;
22
+ usage: Promise<AiUsageRecord | undefined>;
23
+ };
@@ -0,0 +1,146 @@
1
+ /**
2
+ * What a call on an org's own AI key used, read from the provider's answer as
3
+ * classic's runtime reads it. For analytics only: the org pays the provider.
4
+ * OpenAI and Anthropic; the host meters Zite's own key itself.
5
+ */
6
+ export const AI_USAGE_HOSTS = {
7
+ "api.openai.com": "openai",
8
+ "api.anthropic.com": "anthropic",
9
+ };
10
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
11
+ const isNumber = (value) => typeof value === "number" && Number.isFinite(value);
12
+ /** As classic: an OpenAI chat stream reports its usage only when asked to. The body to send instead, if any. */
13
+ export const withStreamUsage = (url, method, body) => {
14
+ if (method !== "POST" || typeof body !== "string" || !url.pathname.endsWith("/chat/completions"))
15
+ return undefined;
16
+ try {
17
+ const json = JSON.parse(body);
18
+ if (!isRecord(json) || json.stream !== true)
19
+ return undefined;
20
+ const options = isRecord(json.stream_options) ? json.stream_options : {};
21
+ if (options.include_usage === true)
22
+ return undefined;
23
+ return JSON.stringify({ ...json, stream_options: { ...options, include_usage: true } });
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ };
29
+ // OpenAI counts cached tokens inside its input; classic takes them apart.
30
+ const tokensFromJson = (provider, body) => {
31
+ const tokens = {};
32
+ if (!isRecord(body))
33
+ return tokens;
34
+ if (provider === "openai") {
35
+ const source = isRecord(body.response) ? body.response : body;
36
+ if (typeof source.model === "string")
37
+ tokens.model = source.model;
38
+ const usage = isRecord(source.usage) ? source.usage : undefined;
39
+ if (!usage)
40
+ return tokens;
41
+ const details = (name) => (isRecord(usage[name]) ? usage[name] : {});
42
+ const cached = details("prompt_tokens_details").cached_tokens ?? details("input_tokens_details").cached_tokens;
43
+ const input = usage.prompt_tokens ?? usage.input_tokens;
44
+ const output = usage.completion_tokens ?? usage.output_tokens;
45
+ if (isNumber(input))
46
+ tokens.inputTokens = input - (isNumber(cached) ? cached : 0);
47
+ if (isNumber(output))
48
+ tokens.outputTokens = output;
49
+ if (isNumber(cached))
50
+ tokens.cachedReadInputTokens = cached;
51
+ return tokens;
52
+ }
53
+ // A stream splits these: message_start has the input, message_delta the running output.
54
+ const message = isRecord(body.message) ? body.message : body;
55
+ if (typeof message.model === "string")
56
+ tokens.model = message.model;
57
+ const usage = isRecord(message.usage) ? message.usage : isRecord(body.usage) ? body.usage : undefined;
58
+ if (!usage)
59
+ return tokens;
60
+ if (isNumber(usage.input_tokens))
61
+ tokens.inputTokens = usage.input_tokens;
62
+ if (isNumber(usage.output_tokens))
63
+ tokens.outputTokens = usage.output_tokens;
64
+ if (isNumber(usage.cache_read_input_tokens))
65
+ tokens.cachedReadInputTokens = usage.cache_read_input_tokens;
66
+ if (isNumber(usage.cache_creation_input_tokens))
67
+ tokens.cacheCreationInputTokens = usage.cache_creation_input_tokens;
68
+ return tokens;
69
+ };
70
+ // Later readings win: Anthropic's output is cumulative, OpenAI's usage comes last.
71
+ const merge = (into, next) => ({
72
+ model: next.model ?? into.model,
73
+ inputTokens: next.inputTokens ?? into.inputTokens,
74
+ outputTokens: next.outputTokens ?? into.outputTokens,
75
+ cachedReadInputTokens: next.cachedReadInputTokens ?? into.cachedReadInputTokens,
76
+ cacheCreationInputTokens: next.cacheCreationInputTokens ?? into.cacheCreationInputTokens,
77
+ });
78
+ const MAX_PENDING_LINE = 1024 * 1024;
79
+ const tokensFromStream = async (provider, stream) => {
80
+ const reader = stream.getReader();
81
+ const decoder = new TextDecoder();
82
+ let pending = "";
83
+ let tokens = {};
84
+ const readLine = (line) => {
85
+ if (!line.startsWith("data:"))
86
+ return;
87
+ const payload = line.slice(5).trim();
88
+ if (!payload || payload === "[DONE]")
89
+ return;
90
+ try {
91
+ tokens = merge(tokens, tokensFromJson(provider, JSON.parse(payload)));
92
+ }
93
+ catch {
94
+ // Not JSON; nothing to count.
95
+ }
96
+ };
97
+ try {
98
+ for (;;) {
99
+ const { done, value } = await reader.read();
100
+ if (done)
101
+ break;
102
+ pending += decoder.decode(value, { stream: true });
103
+ const lines = pending.split("\n");
104
+ pending = lines.pop() ?? "";
105
+ if (pending.length > MAX_PENDING_LINE)
106
+ pending = "";
107
+ lines.forEach(readLine);
108
+ }
109
+ readLine(pending);
110
+ }
111
+ catch {
112
+ // What was read before the stream broke still counts.
113
+ }
114
+ return tokens;
115
+ };
116
+ const counted = (tokens) => tokens.inputTokens !== undefined || tokens.outputTokens !== undefined;
117
+ const usageOf = (provider, tokens, streaming) => counted(tokens)
118
+ ? {
119
+ provider,
120
+ ...Object.fromEntries(Object.entries(tokens).filter(([, value]) => value !== undefined)),
121
+ ...(streaming && { isStreaming: true }),
122
+ }
123
+ : undefined;
124
+ /** The response to hand the app, and its usage once read; a stream is split so the app reads one copy as it arrives. */
125
+ export const meterAiResponse = (provider, response) => {
126
+ const type = response.headers.get("content-type") ?? "";
127
+ if (!response.ok || !response.body)
128
+ return { response, usage: Promise.resolve(undefined) };
129
+ if (type.includes("text/event-stream")) {
130
+ const [forApp, forUs] = response.body.tee();
131
+ return {
132
+ response: new Response(forApp, response),
133
+ usage: tokensFromStream(provider, forUs).then((tokens) => usageOf(provider, tokens, true)),
134
+ };
135
+ }
136
+ if (!type.includes("json"))
137
+ return { response, usage: Promise.resolve(undefined) };
138
+ return {
139
+ response,
140
+ usage: response
141
+ .clone()
142
+ .json()
143
+ .then((json) => usageOf(provider, tokensFromJson(provider, json), false))
144
+ .catch(() => undefined),
145
+ };
146
+ };
@@ -2,10 +2,7 @@ import { type AirtableSettings } from "./dispatch.js";
2
2
  import type { PlatformSession, ZiteRequestContext } from "../types.js";
3
3
  /** The build's Airtable integration `integrationId`, or undefined when it isn't one, or the host can't say. */
4
4
  export declare const airtableSettingsOf: (session: PlatformSession, integrationId: string) => Promise<AirtableSettings | undefined>;
5
- /**
6
- * One generated Airtable table call, run here with the connection's placeholder
7
- * token; egress swaps the real one in for Airtable alone, and refreshes it.
8
- */
5
+ /** One generated Airtable table call, run here with the connection's token, which the outbound fetch refreshes on a 401. */
9
6
  export declare function runAirtableCall(host: ZiteRequestContext, session: PlatformSession, integrationId: string, settings: AirtableSettings, methodName: string, params: unknown, access: "signedIn" | "anyone" | "webhook" | "workflow"): Promise<unknown>;
10
7
  /** For tests: forget what this isolate learned. */
11
8
  export declare const resetAirtableSettings: () => void;
@@ -1,8 +1,8 @@
1
1
  import { executeAirtableCall } from "./dispatch.js";
2
+ import { INTEGRATIONS_PLATFORM_ID } from "../credentials.js";
2
3
  import { ZiteError, isZiteErrorCode } from "../errors.js";
4
+ import { rememberConnection } from "../outbound.js";
3
5
  const AIRTABLE_API = "https://api.airtable.com";
4
- // The platform id `integrations.get` is answered under (`INTEGRATIONS_SDK_INTEGRATION_ID`).
5
- const CONNECTIONS = "__connections__";
6
6
  // A build's integrations don't change, so per isolate: only answers, never a pending call.
7
7
  const settingsHeld = new Map();
8
8
  let unsupported = false;
@@ -29,21 +29,20 @@ export const airtableSettingsOf = async (session, integrationId) => {
29
29
  throw error;
30
30
  }
31
31
  };
32
- /**
33
- * One generated Airtable table call, run here with the connection's placeholder
34
- * token; egress swaps the real one in for Airtable alone, and refreshes it.
35
- */
32
+ /** One generated Airtable table call, run here with the connection's token, which the outbound fetch refreshes on a 401. */
36
33
  export async function runAirtableCall(host, session, integrationId, settings, methodName, params, access) {
37
34
  // As classic: a demo reaches no connected account.
38
35
  if (host.mode === "demo")
39
36
  throw new ZiteError("FORBIDDEN", "This action is not available in demo mode");
40
- const connection = await session.call({
41
- integrationId: CONNECTIONS,
42
- className: "Connections",
43
- methodName: "get",
44
- params: { name: integrationId },
45
- access,
46
- });
37
+ const connection = host.credentials
38
+ ? await host.credentials.get(integrationId)
39
+ : await session.call({
40
+ integrationId: INTEGRATIONS_PLATFORM_ID,
41
+ className: "Connections",
42
+ methodName: "get",
43
+ params: { name: integrationId },
44
+ access,
45
+ });
47
46
  if ("error" in connection) {
48
47
  const { code, message, userFacingMessage } = connection.error;
49
48
  throw new ZiteError({ code: isZiteErrorCode(code) ? code : "INTERNAL_ERROR", message, userFacingMessage });
@@ -52,6 +51,7 @@ export async function runAirtableCall(host, session, integrationId, settings, me
52
51
  if (typeof token !== "string") {
53
52
  throw new ZiteError("INTERNAL_ERROR", `The ${integrationId} connection has no token`);
54
53
  }
54
+ rememberConnection(host, integrationId, connection.value);
55
55
  host.airtableReads ??= new Map();
56
56
  return executeAirtableCall({
57
57
  settings,
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Where `integrations.get` reads a connection. In Zite's cloud the host
3
+ * answers it on the request's session; an app run elsewhere names its own
4
+ * source in its server entry: memory or the environment here, a shared cache
5
+ * once one exists.
6
+ */
7
+ import type { PlatformCallInput, PlatformCallResult, PlatformSession } from "./types.js";
8
+ export type CredentialSource = {
9
+ get(name: string): Promise<PlatformCallResult>;
10
+ /** After a provider answered 401: the connection refreshed, where the source can. */
11
+ refresh?(name: string): Promise<PlatformCallResult>;
12
+ };
13
+ export declare const INTEGRATIONS_PLATFORM_ID = "__connections__";
14
+ /** The host's, on the request's session. */
15
+ export declare const sessionCredentials: (session: PlatformSession, access: PlatformCallInput["access"]) => CredentialSource;
16
+ /** Connections held in memory, by name, as `integrations.get` answers them; `refresh` fetches one anew. */
17
+ export declare const memoryCredentials: (connections: Record<string, unknown>, refresh?: (name: string) => Promise<unknown>) => CredentialSource;
18
+ /** Connections from `ZITE_CONNECTIONS`, JSON by name, for an app run outside Zite. */
19
+ export declare const envCredentials: (env?: Record<string, string | undefined>) => CredentialSource;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Where `integrations.get` reads a connection. In Zite's cloud the host
3
+ * answers it on the request's session; an app run elsewhere names its own
4
+ * source in its server entry: memory or the environment here, a shared cache
5
+ * once one exists.
6
+ */
7
+ export const INTEGRATIONS_PLATFORM_ID = "__connections__";
8
+ /** The host's, on the request's session. */
9
+ export const sessionCredentials = (session, access) => ({
10
+ get: (name) => session.call({ integrationId: INTEGRATIONS_PLATFORM_ID, className: "Connections", methodName: "get", params: { name }, access }),
11
+ ...(session.refreshConnection && { refresh: (name) => session.refreshConnection(name) }),
12
+ });
13
+ const notFound = (name) => ({
14
+ error: { code: "BAD_REQUEST", message: `Integration ${name} not found` },
15
+ });
16
+ /** Connections held in memory, by name, as `integrations.get` answers them; `refresh` fetches one anew. */
17
+ export const memoryCredentials = (connections, refresh) => ({
18
+ get: async (name) => (Object.hasOwn(connections, name) ? { value: connections[name] } : notFound(name)),
19
+ ...(refresh && {
20
+ refresh: async (name) => {
21
+ const fresh = await refresh(name);
22
+ if (fresh === undefined)
23
+ return notFound(name);
24
+ connections[name] = fresh;
25
+ return { value: fresh };
26
+ },
27
+ }),
28
+ });
29
+ /** Connections from `ZITE_CONNECTIONS`, JSON by name, for an app run outside Zite. */
30
+ export const envCredentials = (env = process.env) => memoryCredentials(JSON.parse(env.ZITE_CONNECTIONS ?? "{}"));
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Every fetch an app makes on the server, wrapped as classic's runtime wraps
3
+ * it: logged, every secret it knows redacted, with the connection whose token
4
+ * it carries and what an org's own AI key used; a 401 on a connection's token
5
+ * refreshed and sent once more; and a call on Zite's own AI key sent to the
6
+ * host over the app's platform binding.
7
+ */
8
+ import type { ZiteRequestContext } from "./types.js";
9
+ /** An `integrations.get` answer, whose values name the calls that carry them. */
10
+ export declare const rememberConnection: (host: ZiteRequestContext, name: string, connection: unknown) => void;
11
+ export declare const createOutboundFetch: (nativeFetch: typeof fetch) => typeof fetch;
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Every fetch an app makes on the server, wrapped as classic's runtime wraps
3
+ * it: logged, every secret it knows redacted, with the connection whose token
4
+ * it carries and what an org's own AI key used; a 401 on a connection's token
5
+ * refreshed and sent once more; and a call on Zite's own AI key sent to the
6
+ * host over the app's platform binding.
7
+ */
8
+ import { AI_USAGE_HOSTS, meterAiResponse, withStreamUsage } from "./aiUsage.js";
9
+ import { sessionCredentials } from "./credentials.js";
10
+ import { REDACTED, redactKnown, secretForms } from "./redactSecrets.js";
11
+ import { getHost } from "./scope.js";
12
+ const MAX_FETCHES = 500;
13
+ // Shorter values (a region, a flag) aren't tokens, and could match by chance.
14
+ const MIN_TOKEN_LENGTH = 8;
15
+ // As classic: Workers send no User-Agent, and some APIs (GitHub) refuse a request without one.
16
+ const DEFAULT_USER_AGENT = "zite";
17
+ // The backend's stand-in for Zite's own AI key, which only the host answers.
18
+ const AI_MARKER = /^zite_ai_gateway:[a-z]+:(.+)$/;
19
+ // Where a provider SDK given the connection's `baseUrl` sends it; it never resolves.
20
+ const AI_HOST = "zite-ai.invalid";
21
+ // A provider's own base URL at the host: `/v1` is OpenAI's, Anthropic's SDK sends it in the path.
22
+ const AI_PROVIDER_HOSTS = {
23
+ "api.openai.com": { provider: "openai", strip: "/v1" },
24
+ "api.anthropic.com": { provider: "anthropic", strip: "" },
25
+ };
26
+ // As classic's run log: a body up to 50 KB, a request's fetch bodies up to 200 KB in all.
27
+ const MAX_BODY_CHARS = 50 * 1024;
28
+ const MAX_LOGGED_CHARS = 200 * 1024;
29
+ const MAX_URL_CHARS = 2_000;
30
+ const MAX_ERROR_CHARS = 1_000;
31
+ const SECRET_HEADER = /auth|key|token|secret|signature|password|cookie|session/i;
32
+ // Usage still being read when the request's log is flushed isn't waited on for longer.
33
+ const USAGE_WAIT_MS = 10_000;
34
+ // Tokens refreshed in this isolate, which a later request's props may not name yet.
35
+ const MAX_REFRESHED = 50;
36
+ const refreshedTokens = new Map();
37
+ const replacements = new Map();
38
+ // Moves with each refresh, so a request's list of secrets takes in a token another one refreshed.
39
+ let refreshes = 0;
40
+ const remember = (map, key, value) => {
41
+ map.delete(key);
42
+ map.set(key, value);
43
+ if (map.size > MAX_REFRESHED)
44
+ map.delete(map.keys().next().value);
45
+ };
46
+ const tokensOf = (host) => {
47
+ if (host.connectionTokens)
48
+ return host.connectionTokens;
49
+ const tokens = new Map(refreshedTokens);
50
+ for (const [key, name] of Object.entries(host.envConnections ?? {})) {
51
+ const value = host.env?.[key];
52
+ if (value && value.length >= MIN_TOKEN_LENGTH)
53
+ tokens.set(value, name);
54
+ }
55
+ host.connectionTokens = tokens;
56
+ return tokens;
57
+ };
58
+ /** An `integrations.get` answer, whose values name the calls that carry them. */
59
+ export const rememberConnection = (host, name, connection) => {
60
+ (host.connectionAnswers ??= new Map()).set(name, connection);
61
+ host.secretValues = undefined;
62
+ const tokens = tokensOf(host);
63
+ for (const [field, value] of Object.entries((connection ?? {}))) {
64
+ if (field !== "status" && field !== "baseUrl" && typeof value === "string" && value.length >= MIN_TOKEN_LENGTH) {
65
+ tokens.set(value, name);
66
+ }
67
+ }
68
+ };
69
+ const bare = (value) => value.replace(/^(?:Bearer|Token)\s+/i, "");
70
+ const isUrl = (value) => /^https?:\/\//i.test(value);
71
+ /** Every value this request knows is secret, longest first. */
72
+ const secretsOf = (host) => {
73
+ if (host.secretValues && host.secretsAt === refreshes)
74
+ return host.secretValues;
75
+ const values = new Set([...refreshedTokens.keys(), ...replacements.values()]);
76
+ // A connection's own URL (an instance, an account) says which call failed; a user's secret may be a URL.
77
+ const add = (value, urls) => {
78
+ if (typeof value !== "string" || value.length < MIN_TOKEN_LENGTH || AI_MARKER.test(value))
79
+ return;
80
+ if (urls || !isUrl(value))
81
+ values.add(value);
82
+ };
83
+ for (const answer of host.connectionAnswers?.values() ?? []) {
84
+ for (const [field, value] of Object.entries((answer ?? {}))) {
85
+ if (field !== "status" && field !== "baseUrl")
86
+ add(value, false);
87
+ }
88
+ }
89
+ for (const [key, value] of Object.entries(host.env ?? {}))
90
+ add(value, !host.envConnections?.[key]);
91
+ host.secretValues = [...values].sort((a, b) => b.length - a.length);
92
+ host.secretsAt = refreshes;
93
+ return host.secretValues;
94
+ };
95
+ /** The values a request's own credential headers carry, which the log redacts too. */
96
+ const headerSecrets = (headers) => [...headers].flatMap(([name, value]) => {
97
+ const secret = bare(value);
98
+ return SECRET_HEADER.test(name) && secret.length >= MIN_TOKEN_LENGTH && !AI_MARKER.test(secret) ? [secret] : [];
99
+ });
100
+ /** `text` cut to `max`, every secret in it redacted, none cut in half first, however it was written. */
101
+ const loggable = (text, secrets, max) => {
102
+ const reach = secrets.reduce((longest, secret) => Math.max(longest, secretForms(secret)[0].length), 0);
103
+ return redactKnown(text.slice(0, max + reach), secrets).slice(0, max);
104
+ };
105
+ const decodings = (raw) => {
106
+ const decoded = [raw];
107
+ for (const text of [raw, raw.replace(/\+/g, " ")]) {
108
+ try {
109
+ decoded.push(decodeURIComponent(text));
110
+ }
111
+ catch {
112
+ // Not encoded as a whole; matched as written.
113
+ }
114
+ }
115
+ return decoded;
116
+ };
117
+ /** A URL with each path segment or query value that holds a secret, however it was encoded, redacted whole. */
118
+ const redactedUrl = (url, secrets) => {
119
+ const holds = (raw) => decodings(raw).some((text) => secrets.some((secret) => text.includes(secret)));
120
+ const path = url.pathname
121
+ .split("/")
122
+ .map((segment) => (holds(segment) ? REDACTED : segment))
123
+ .join("/");
124
+ const query = url.search
125
+ ? `?${url.search
126
+ .slice(1)
127
+ .split("&")
128
+ .map((pair) => {
129
+ const at = pair.indexOf("=");
130
+ if (at < 0)
131
+ return holds(pair) ? REDACTED : pair;
132
+ return holds(pair.slice(at + 1)) ? `${pair.slice(0, at)}=${REDACTED}` : pair;
133
+ })
134
+ .join("&")}`
135
+ : "";
136
+ return `${url.protocol}//${url.host}${path}${query}`;
137
+ };
138
+ /** A request body as classic's run log shows it. */
139
+ const bodyText = (body) => {
140
+ if (body === undefined || body === null)
141
+ return undefined;
142
+ if (typeof body === "string")
143
+ return body;
144
+ if (body instanceof URLSearchParams)
145
+ return body.toString();
146
+ if (body instanceof ReadableStream)
147
+ return "[stream]";
148
+ if (body instanceof Blob || body instanceof ArrayBuffer || ArrayBuffer.isView(body))
149
+ return "[binary data]";
150
+ if (typeof FormData !== "undefined" && body instanceof FormData)
151
+ return "[binary data]";
152
+ try {
153
+ return JSON.stringify(body);
154
+ }
155
+ catch {
156
+ return "[non-serializable body]";
157
+ }
158
+ };
159
+ const settledWithin = (promise, ms) => new Promise((resolve) => {
160
+ const timer = setTimeout(resolve, ms);
161
+ promise.then(() => {
162
+ clearTimeout(timer);
163
+ resolve();
164
+ }, () => {
165
+ clearTimeout(timer);
166
+ resolve();
167
+ });
168
+ });
169
+ /** The connection token a request carries, in a header or its query. */
170
+ const carried = (headers, url, tokens) => {
171
+ for (const [, value] of headers) {
172
+ const connection = tokens.get(bare(value));
173
+ if (connection)
174
+ return { token: bare(value), connection };
175
+ }
176
+ for (const [, value] of url.searchParams) {
177
+ const connection = tokens.get(value);
178
+ if (connection)
179
+ return { token: value, connection };
180
+ }
181
+ return undefined;
182
+ };
183
+ /** `url` with each query value equal to `from` set to `to`, every other pair left as it was written. */
184
+ const withQueryValue = (url, from, to) => {
185
+ if (!url.search)
186
+ return url;
187
+ const pairs = url.search
188
+ .slice(1)
189
+ .split("&")
190
+ .map((pair) => {
191
+ const at = pair.indexOf("=");
192
+ if (at < 0)
193
+ return pair;
194
+ let value;
195
+ try {
196
+ value = decodeURIComponent(pair.slice(at + 1).replace(/\+/g, " "));
197
+ }
198
+ catch {
199
+ return pair;
200
+ }
201
+ return value === from ? `${pair.slice(0, at)}=${encodeURIComponent(to)}` : pair;
202
+ });
203
+ const swapped = new URL(url);
204
+ swapped.search = `?${pairs.join("&")}`;
205
+ return swapped;
206
+ };
207
+ const swapHeaders = (headers, from, to) => {
208
+ for (const [name, value] of [...headers]) {
209
+ if (value.includes(from))
210
+ headers.set(name, value.split(from).join(to));
211
+ }
212
+ };
213
+ /** Where a call on Zite's AI key goes at the host, whether the app named the provider or the `baseUrl` it was given. */
214
+ const aiTarget = (url, headers) => {
215
+ const marker = [...headers].map(([, value]) => AI_MARKER.exec(bare(value))).find(Boolean);
216
+ if (url.hostname === AI_HOST)
217
+ return { target: url, connection: marker?.[1] };
218
+ const route = AI_PROVIDER_HOSTS[url.hostname];
219
+ if (!route || !marker)
220
+ return undefined;
221
+ const path = url.pathname.startsWith(route.strip) ? url.pathname.slice(route.strip.length) : url.pathname;
222
+ return { target: new URL(`https://${AI_HOST}/${route.provider}${path}${url.search}`), connection: marker[1] };
223
+ };
224
+ const sessionOf = async (host) => {
225
+ if (!host.platform)
226
+ return undefined;
227
+ host.session ??= Promise.resolve(host.watch ? host.platform.session(host.watch) : host.platform.session());
228
+ return host.session;
229
+ };
230
+ const credentialsOf = async (host) => {
231
+ if (host.credentials)
232
+ return host.credentials;
233
+ const session = await sessionOf(host);
234
+ return session && sessionCredentials(session, "anyone");
235
+ };
236
+ /** The token to send instead of `token`, refreshed once per connection per request. */
237
+ const freshToken = async (host, token, connection) => {
238
+ const known = replacements.get(token);
239
+ if (known)
240
+ return known;
241
+ const refreshing = (host.refreshing ??= new Map());
242
+ let pending = refreshing.get(connection);
243
+ if (!pending) {
244
+ pending = (async () => {
245
+ const source = await credentialsOf(host);
246
+ const result = source?.refresh ? await source.refresh(connection) : undefined;
247
+ if (!result || !("value" in result))
248
+ return undefined;
249
+ const fresh = result.value;
250
+ const previous = (host.connectionAnswers?.get(connection) ?? {});
251
+ const field = Object.keys(previous).find((key) => previous[key] === token) ?? "accessToken";
252
+ const value = fresh[field];
253
+ rememberConnection(host, connection, fresh);
254
+ return typeof value === "string" ? value : undefined;
255
+ })();
256
+ refreshing.set(connection, pending);
257
+ }
258
+ const value = await pending;
259
+ if (!value || value === token)
260
+ return undefined;
261
+ remember(replacements, token, value);
262
+ remember(refreshedTokens, value, connection);
263
+ refreshes += 1;
264
+ // As classic: the env vars that held the old token hold the new one.
265
+ const env = globalThis.process?.env;
266
+ for (const [key, held] of Object.entries(env ?? {})) {
267
+ if (held === token)
268
+ env[key] = value;
269
+ }
270
+ return value;
271
+ };
272
+ const hostOf = (input) => {
273
+ try {
274
+ return new URL(input instanceof Request ? input.url : input).host;
275
+ }
276
+ catch {
277
+ return "?";
278
+ }
279
+ };
280
+ const aiUnavailable = () => Response.json({ error: { type: "unavailable", message: "Zite's AI key works only in an app Zite hosts" } }, { status: 503 });
281
+ const cfOf = (request) => request.cf;
282
+ export const createOutboundFetch = (nativeFetch) => async (input, init) => {
283
+ const host = getHost();
284
+ if (!host)
285
+ return nativeFetch(input, init);
286
+ // A page that fetched depends on more than the reads the host saw, so the
287
+ // host must hear of it before the fetch goes out.
288
+ if (host.watch)
289
+ await host.watch.uncacheable(`fetch ${hostOf(input)}`);
290
+ let url;
291
+ try {
292
+ url = new URL(input instanceof Request ? input.url : input);
293
+ }
294
+ catch {
295
+ return nativeFetch(input, init);
296
+ }
297
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
298
+ if (!headers.has("user-agent"))
299
+ headers.set("user-agent", DEFAULT_USER_AGENT);
300
+ const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase();
301
+ const ai = aiTarget(url, headers);
302
+ const found = ai ? undefined : carried(headers, url, tokensOf(host));
303
+ const startedAt = Date.now();
304
+ const sentBody = bodyText(init?.body);
305
+ const sentHeaderSecrets = headerSecrets(headers);
306
+ const record = (outcome) => {
307
+ const fetches = (host.fetches ??= []);
308
+ if (fetches.length >= MAX_FETCHES)
309
+ return undefined;
310
+ const connection = ai?.connection ?? found?.connection;
311
+ const secrets = [...sentHeaderSecrets, ...secretsOf(host)].sort((a, b) => b.length - a.length);
312
+ const bodyRoom = Math.min(MAX_BODY_CHARS, MAX_LOGGED_CHARS - (host.fetchLogChars ?? 0));
313
+ const requestBody = sentBody !== undefined && bodyRoom > 0 ? loggable(sentBody, secrets, bodyRoom) : undefined;
314
+ if (requestBody)
315
+ host.fetchLogChars = (host.fetchLogChars ?? 0) + requestBody.length;
316
+ const entry = {
317
+ at: startedAt,
318
+ method,
319
+ url: loggable(redactedUrl(url, secrets), secrets, MAX_URL_CHARS),
320
+ durationMs: Date.now() - startedAt,
321
+ ...(requestBody !== undefined && { requestBody }),
322
+ ...(outcome.status !== undefined && { status: outcome.status }),
323
+ ...(outcome.error !== undefined && {
324
+ error: loggable(outcome.error instanceof Error ? outcome.error.message : String(outcome.error), secrets, MAX_ERROR_CHARS),
325
+ }),
326
+ ...(connection && { connection }),
327
+ ...(ai && { ziteAi: true }),
328
+ ...(outcome.retried && { retried: true }),
329
+ };
330
+ fetches.push(entry);
331
+ return entry;
332
+ };
333
+ if (!ai && !found) {
334
+ try {
335
+ // The app's own call, `cf` and all; only the headers are ours.
336
+ const response = await nativeFetch(input, { ...init, headers });
337
+ record({ status: response.status });
338
+ return response;
339
+ }
340
+ catch (error) {
341
+ record({ error });
342
+ throw error;
343
+ }
344
+ }
345
+ // An org's own AI key, which classic reads usage from for analytics.
346
+ const usageProvider = found ? AI_USAGE_HOSTS[url.hostname] : undefined;
347
+ const usageBody = usageProvider ? withStreamUsage(url, method, init?.body) : undefined;
348
+ if (usageBody !== undefined)
349
+ headers.delete("content-length");
350
+ const base = new Request(input, usageBody === undefined ? init : { ...init, body: usageBody });
351
+ if (ai) {
352
+ try {
353
+ const session = await sessionOf(host);
354
+ if (!session?.aiFetch) {
355
+ const refused = aiUnavailable();
356
+ record({ status: refused.status });
357
+ return refused;
358
+ }
359
+ // Whole, so it crosses RPC as bytes whatever stream the app built it from.
360
+ const body = base.body ? await base.arrayBuffer() : undefined;
361
+ const response = await session.aiFetch(new Request(ai.target, { method: base.method, headers, body, redirect: "manual" }));
362
+ record({ status: response.status });
363
+ return response;
364
+ }
365
+ catch (error) {
366
+ record({ error });
367
+ throw error;
368
+ }
369
+ }
370
+ // As classic: Node SDKs ask for gzip and inflate it themselves, which fails on the body Workers already inflated.
371
+ if (found)
372
+ headers.delete("accept-encoding");
373
+ const outgoing = new Request(base, { headers });
374
+ const retry = found ? outgoing.clone() : undefined;
375
+ try {
376
+ let response = await nativeFetch(outgoing);
377
+ let retried = false;
378
+ if (response.status === 401 && retry && found) {
379
+ const fresh = await freshToken(host, found.token, found.connection);
380
+ if (fresh) {
381
+ const retryHeaders = new Headers(retry.headers);
382
+ swapHeaders(retryHeaders, found.token, fresh);
383
+ await response.body?.cancel();
384
+ response = await nativeFetch(new Request(withQueryValue(url, found.token, fresh), {
385
+ method: retry.method,
386
+ headers: retryHeaders,
387
+ body: retry.body,
388
+ redirect: retry.redirect,
389
+ signal: retry.signal,
390
+ ...(retry.body && { duplex: "half" }),
391
+ ...(cfOf(retry) !== undefined && { cf: cfOf(retry) }),
392
+ }));
393
+ retried = true;
394
+ }
395
+ }
396
+ if (!retried)
397
+ await retry?.body?.cancel();
398
+ if (usageProvider) {
399
+ const metered = meterAiResponse(usageProvider, response);
400
+ response = metered.response;
401
+ const entry = record({ status: response.status, retried });
402
+ const used = metered.usage.then((usage) => {
403
+ if (usage && entry)
404
+ entry.ai = usage;
405
+ });
406
+ (host.pending ??= []).push(settledWithin(used, USAGE_WAIT_MS));
407
+ return response;
408
+ }
409
+ record({ status: response.status, retried });
410
+ return response;
411
+ }
412
+ catch (error) {
413
+ await retry?.body?.cancel().catch(() => { });
414
+ record({ error });
415
+ throw error;
416
+ }
417
+ };
@@ -5,3 +5,11 @@ export declare const REDACTED = "REDACTED";
5
5
  * callers that pass clean text see byte-identical output.
6
6
  */
7
7
  export declare function redactSecrets(text: string): string;
8
+ /** The ways a secret is written into text, longest first: as it is, URL-encoded in either case, form-encoded, JSON-escaped. */
9
+ export declare function secretForms(value: string): string[];
10
+ /**
11
+ * `text` with each of `values`, however it was written, replaced, then
12
+ * anything the patterns above find. `values` longest first, so none is left
13
+ * half-done.
14
+ */
15
+ export declare function redactKnown(text: string, values: readonly string[]): string;
@@ -129,3 +129,26 @@ export function redactSecrets(text) {
129
129
  }
130
130
  return result;
131
131
  }
132
+ /** The ways a secret is written into text, longest first: as it is, URL-encoded in either case, form-encoded, JSON-escaped. */
133
+ export function secretForms(value) {
134
+ const lower = (text) => text.replace(/%[0-9A-F]{2}/g, (hex) => hex.toLowerCase());
135
+ const encoded = encodeURIComponent(value);
136
+ const form = new URLSearchParams({ v: value }).toString().slice(2);
137
+ const json = JSON.stringify(value).slice(1, -1);
138
+ return [...new Set([value, encoded, lower(encoded), form, lower(form), json])].sort((a, b) => b.length - a.length);
139
+ }
140
+ /**
141
+ * `text` with each of `values`, however it was written, replaced, then
142
+ * anything the patterns above find. `values` longest first, so none is left
143
+ * half-done.
144
+ */
145
+ export function redactKnown(text, values) {
146
+ let result = text;
147
+ for (const value of values) {
148
+ for (const form of secretForms(value)) {
149
+ if (result.includes(form))
150
+ result = result.split(form).join(REDACTED);
151
+ }
152
+ }
153
+ return redactSecrets(result);
154
+ }
@@ -1,3 +1,4 @@
1
+ import { type CredentialSource } from "./credentials.js";
1
2
  import type { HostProps, PlatformBinding, RenderWatch, ZiteRequestContext } from "./types.js";
2
3
  export { runWithHost } from "./scope.js";
3
4
  export { describeApp } from "./describe.js";
@@ -39,6 +40,8 @@ export type ZiteServerOptions = {
39
40
  startInstance?: {
40
41
  getOptions: () => unknown;
41
42
  };
43
+ /** Where `integrations.get` reads outside Zite; the host's requests always read the host. */
44
+ credentials?: CredentialSource;
42
45
  };
43
46
  type HostedContext = WorkerContext & {
44
47
  props?: Partial<HostProps>;
@@ -10,6 +10,8 @@ import { errorOf, preview } from "./runLog.js";
10
10
  import { DATA_INTEGRATION_ID, dataContextOf, runDataCall } from "./data/index.js";
11
11
  import { airtableSettingsOf, runAirtableCall } from "./airtable/index.js";
12
12
  import { withoutConsole } from "./quiet.js";
13
+ import { createOutboundFetch, rememberConnection } from "./outbound.js";
14
+ import { INTEGRATIONS_PLATFORM_ID } from "./credentials.js";
13
15
  import { getHost, requireScope, runInScope, runWithHost } from "./scope.js";
14
16
  import { ZITEJS_VERSION } from "./version.js";
15
17
  import { loadWorkflows, loggedInput, runWorkflow } from "./workflowRun.js";
@@ -25,6 +27,11 @@ export function installPlatformTransport() {
25
27
  const sdkCall = async (integrationId, className, methodName, params) => {
26
28
  const scope = requireScope(`${className}.${methodName}`);
27
29
  const host = getHost();
30
+ const getsConnection = integrationId === INTEGRATIONS_PLATFORM_ID && methodName === "get";
31
+ const name = String(params?.name ?? "");
32
+ // An app run outside Zite reads its connections from its own source, with no platform at all.
33
+ if (getsConnection && host?.credentials)
34
+ return connectionAnswer(host, name, await host.credentials.get(name));
28
35
  // Fails closed: no platform means no platform access, never a fallback.
29
36
  if (!host?.platform)
30
37
  throw new Error("The Zite platform binding is missing");
@@ -43,6 +50,8 @@ export function installPlatformTransport() {
43
50
  }
44
51
  }
45
52
  const result = await session.call({ integrationId, className, methodName, params, access: scope.kind });
53
+ if (getsConnection)
54
+ return connectionAnswer(host, name, result);
46
55
  if ("value" in result)
47
56
  return result.value;
48
57
  const { code, message, userFacingMessage } = result.error;
@@ -51,6 +60,15 @@ export function installPlatformTransport() {
51
60
  globalThis.__wrapSdkCall = sdkCall;
52
61
  watchOutboundFetches();
53
62
  }
63
+ /** `integrations.get`'s answer, whose values then name the calls made with them. */
64
+ const connectionAnswer = (host, name, result) => {
65
+ if ("value" in result) {
66
+ rememberConnection(host, name, result.value);
67
+ return result.value;
68
+ }
69
+ const { code, message, userFacingMessage } = result.error;
70
+ throw new ZiteError({ code: isZiteErrorCode(code) ? code : "INTERNAL_ERROR", message, userFacingMessage });
71
+ };
54
72
  /** A data call the app made itself, recorded as the host recorded the ones it made. */
55
73
  const recordCall = async (host, call, run) => {
56
74
  const at = Date.now();
@@ -72,28 +90,15 @@ const recordCall = async (host, call, run) => {
72
90
  throw error;
73
91
  }
74
92
  };
75
- let fetchesWatched = false;
76
- const fetchedHost = (input) => {
77
- try {
78
- return new URL(input instanceof Request ? input.url : input).host;
79
- }
80
- catch {
81
- return "?";
82
- }
83
- };
84
- // A page that fetched depends on more than the reads the host saw, so the host
85
- // must hear of it before the fetch goes out. Next patches fetch for the same reason.
93
+ // Keyed on globalThis so two copies of zitejs in one bundle wrap fetch once.
94
+ const WATCHED_KEY = Symbol.for("zitejs:outbound-fetch");
95
+ // Next patches fetch for the same reasons: the page cache must hear of each, and the run log too.
86
96
  const watchOutboundFetches = () => {
87
- if (fetchesWatched)
97
+ const marks = globalThis;
98
+ if (marks[WATCHED_KEY])
88
99
  return;
89
- fetchesWatched = true;
90
- const nativeFetch = globalThis.fetch.bind(globalThis);
91
- globalThis.fetch = async (input, init) => {
92
- const watch = getHost()?.watch;
93
- if (watch)
94
- await watch.uncacheable(`fetch ${fetchedHost(input)}`);
95
- return nativeFetch(input, init);
96
- };
100
+ marks[WATCHED_KEY] = true;
101
+ globalThis.fetch = createOutboundFetch(globalThis.fetch.bind(globalThis));
97
102
  };
98
103
  const decodeUser = (value) => JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0))));
99
104
  const INVOKE_KINDS = new Set(["webhook", "describe", "workflow", "register", "warm", "authHook", "function"]);
@@ -111,6 +116,7 @@ export function hostContextFromProps(request, props) {
111
116
  baseId: props.baseId,
112
117
  previewClaim: props.previewClaim,
113
118
  env: props.env,
119
+ envConnections: props.envConnections,
114
120
  runRoot: props.runRoot,
115
121
  runs: [],
116
122
  platform: props.platform,
@@ -300,7 +306,7 @@ export function createZiteHandlers(options) {
300
306
  const serve = async (request, ctx, watch) => {
301
307
  const host = ctx?.props?.platform
302
308
  ? hostContextFromProps(request, ctx.props)
303
- : hostContextFromRequest(request, options.platform);
309
+ : { ...hostContextFromRequest(request, options.platform), credentials: options.credentials };
304
310
  if (watch)
305
311
  host.watch = watch;
306
312
  setRequestEnv(host);
@@ -332,14 +338,21 @@ export function createZiteHandlers(options) {
332
338
  response = new Response(response.body, { status, headers: response.headers });
333
339
  return onBodyEnd(response, () => {
334
340
  const { platform } = host;
335
- if ((!host.runs.length && !host.calls?.length && !host.pending?.length) || !platform)
341
+ if ((!host.runs.length && !host.calls?.length && !host.fetches?.length && !host.pending?.length) || !platform)
336
342
  return;
337
343
  // On the request's session when it has one: one call, not another binding round trip.
338
344
  const flushed = (async () => {
339
345
  await Promise.all(host.pending ?? []);
340
- if (!host.runs.length && !host.calls?.length)
346
+ if (!host.runs.length && !host.calls?.length && !host.fetches?.length)
341
347
  return;
342
- const batch = { runs: host.runs, ...(host.calls?.length && { calls: host.calls }) };
348
+ const batch = {
349
+ runs: host.runs,
350
+ ...(host.calls?.length && { calls: host.calls }),
351
+ ...(host.fetches?.length && { fetches: host.fetches }),
352
+ };
353
+ // Only a session takes fetches.
354
+ if (host.fetches?.length)
355
+ host.session ??= Promise.resolve(platform.session());
343
356
  const session = host.session ? await host.session : undefined;
344
357
  await (session?.runs ? session.runs(batch) : platform.runs(batch));
345
358
  })().catch((error) => {
@@ -1,3 +1,5 @@
1
+ import type { AiUsageRecord } from "./aiUsage.js";
2
+ import type { CredentialSource } from "./credentials.js";
1
3
  export type Role = {
2
4
  id: string;
3
5
  name: string;
@@ -76,13 +78,37 @@ export type CallRecord = {
76
78
  message: string;
77
79
  };
78
80
  };
81
+ /** An outbound fetch, by host and path (never its query), with the connection whose token it carried. */
82
+ export type FetchRecord = {
83
+ at: number;
84
+ method: string;
85
+ /** Whole, with every secret the request knows redacted. */
86
+ url: string;
87
+ /** As classic's run log shows it: cut short, secrets redacted. */
88
+ requestBody?: string;
89
+ status?: number;
90
+ durationMs: number;
91
+ error?: string;
92
+ connection?: string;
93
+ /** Sent again with a refreshed token after a 401. */
94
+ retried?: true;
95
+ /** On Zite's own AI key, which the host meters itself. */
96
+ ziteAi?: true;
97
+ /** What a call on the org's own AI key used, for analytics. */
98
+ ai?: AiUsageRecord;
99
+ };
79
100
  export type PlatformSession = {
80
101
  call(call: PlatformCallInput): Promise<PlatformCallResult>;
81
- /** As `PlatformBinding.runs`, on a session the request already holds. Hosts before 0.10.0-start.4 lack it. */
102
+ /** As `PlatformBinding.runs`, on a session the request already holds. Hosts before 0.10.0-start.4 lack it; before 0.10.0-start.13, `fetches`. */
82
103
  runs?(batch: {
83
104
  runs: RunRecord[];
84
105
  calls?: CallRecord[];
106
+ fetches?: FetchRecord[];
85
107
  }): Promise<void>;
108
+ /** After a provider answered 401: the connection refreshed, as `integrations.get` now answers it. Hosts before 0.10.0-start.13 lack it. */
109
+ refreshConnection?(name: string): Promise<PlatformCallResult>;
110
+ /** A call on Zite's own AI key, which the host makes with the real key. Hosts before 0.10.0-start.13 lack it. */
111
+ aiFetch?(request: Request): Promise<Response>;
86
112
  /** The build's base and SDK names, which the app's data dispatch translates against. */
87
113
  dataContext?(): Promise<{
88
114
  baseId: string | null;
@@ -136,8 +162,10 @@ export type HostProps = {
136
162
  baseId?: string;
137
163
  /** An editor preview's access, which zitejs keeps on the app's links and calls. */
138
164
  previewClaim?: string;
139
- /** `process.env` as classic sets it: connections' vars, secrets as placeholders egress swaps but S3's, then the user's own secrets. */
165
+ /** `process.env` as classic sets it: connections' vars, then the user's own secrets. */
140
166
  env?: Record<string, string>;
167
+ /** Which connection each token var in `env` holds. Hosts before 0.10.0-start.13 don't send it. */
168
+ envConnections?: Record<string, string>;
141
169
  /** A resumed run's first attempt, whose saved steps it replays. Hosts before 0.10.0-start.10 don't send it. */
142
170
  runRoot?: string;
143
171
  };
@@ -153,8 +181,25 @@ export type ZiteRequestContext = {
153
181
  previewClaim?: string;
154
182
  /** Classic's connection env vars the host sent, set into `process.env` for the request. */
155
183
  env?: Record<string, string>;
184
+ envConnections?: Record<string, string>;
156
185
  /** A resumed run's first attempt, whose saved steps it replays. */
157
186
  runRoot?: string;
187
+ /** Where `integrations.get` reads, when not the host: an entry run outside Zite's names its own. */
188
+ credentials?: CredentialSource;
189
+ /** The connection each token this request was handed belongs to. */
190
+ connectionTokens?: Map<string, string>;
191
+ /** `integrations.get`'s answers this request, by name. */
192
+ connectionAnswers?: Map<string, unknown>;
193
+ /** One refresh per connection per request, however many calls got a 401. */
194
+ refreshing?: Map<string, Promise<string | undefined>>;
195
+ /** The outbound fetches, flushed with its runs. */
196
+ fetches?: FetchRecord[];
197
+ /** The secrets its fetch log redacts, longest first; cleared when a connection is learned. */
198
+ secretValues?: string[];
199
+ /** The isolate's refresh count `secretValues` was read at. */
200
+ secretsAt?: number;
201
+ /** How much of its fetch log is bodies so far. */
202
+ fetchLogChars?: number;
158
203
  /** Filled by run capture, flushed to the platform once the response ends. */
159
204
  runs: RunRecord[];
160
205
  /** The data calls this request made, flushed with its runs. */
@@ -1 +1 @@
1
- export declare const ZITEJS_VERSION = "0.10.0-start.12";
1
+ export declare const ZITEJS_VERSION = "0.10.0-start.13";
@@ -1,2 +1,2 @@
1
1
  // Written by scripts/write-version.mjs from package.json.
2
- export const ZITEJS_VERSION = "0.10.0-start.12";
2
+ export const ZITEJS_VERSION = "0.10.0-start.13";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zitejs",
3
- "version": "0.10.0-start.12",
3
+ "version": "0.10.0-start.13",
4
4
  "description": "The Zite framework — build apps on Zite Database",
5
5
  "type": "module",
6
6
  "sideEffects": false,