@foldspace_npm/harness 0.1.7 → 0.1.9

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,373 @@
1
+ // HTTP against the customer's own API, from inside their signed-in page.
2
+ //
3
+ // Lifted from the Transparency Catalog build. Errors are RETURNED, never
4
+ // thrown, and never carry the token or a raw API error body back to the agent.
5
+ //
6
+ // Two of three early builds used Authorization: Bearer from localStorage, not
7
+ // cookies. Reach for credentials: "include" only when you have observed the
8
+ // app doing that. Custom headers (Joist-style) belong in a tenant override of
9
+ // apiFetch, not here.
10
+
11
+ import { getConfig } from "./config";
12
+
13
+ /**
14
+ * Mask a secret for logs. Never log a raw token.
15
+ *
16
+ * @param value - Token or other secret; null/empty becomes `"<empty>"`
17
+ * @returns `"<redacted:N chars>"` or `"<empty>"`
18
+ */
19
+ export function redact(value: string | null | undefined): string {
20
+ if (!value) return "<empty>";
21
+ return `<redacted:${value.length} chars>`;
22
+ }
23
+
24
+ function readLocalStorageValue(key: string): string | null {
25
+ const raw = window.localStorage.getItem(key);
26
+ if (!raw) return null;
27
+ try {
28
+ const parsed = JSON.parse(raw);
29
+ if (typeof parsed === "string") return parsed;
30
+ if (parsed && typeof parsed === "object") {
31
+ return parsed.access_token ?? parsed.token ?? parsed.value ?? null;
32
+ }
33
+ } catch {
34
+ return raw;
35
+ }
36
+ return raw;
37
+ }
38
+
39
+ function readCookie(name: string): string | null {
40
+ const prefix = `${name}=`;
41
+ for (const part of document.cookie.split(";")) {
42
+ const c = part.trim();
43
+ if (c.startsWith(prefix)) {
44
+ return decodeURIComponent(c.slice(prefix.length)) || null;
45
+ }
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Read the customer app's session token from `authSource`.
52
+ *
53
+ * Returns null when unsigned-in, `AUTH_SOURCE` is unset, or the key is empty.
54
+ * This is the host app token, not a Foldspace credential.
55
+ *
56
+ * @returns The token string, or `null`
57
+ */
58
+ export function getAuthToken(): string | null {
59
+ const source = getConfig().authSource;
60
+ if (!source?.name) return null;
61
+ if (source.kind === "cookie") return readCookie(source.name);
62
+ return readLocalStorageValue(source.name);
63
+ }
64
+
65
+ /**
66
+ * Decode a JWT payload without verifying the signature.
67
+ *
68
+ * Identity hints only (email, sub). Never use this for authorization.
69
+ *
70
+ * @param token - Compact JWT from {@link getAuthToken}
71
+ * @returns Parsed payload, or `null` if the token is not a JWT
72
+ */
73
+ export function parseJwt<T = Record<string, unknown>>(token: string): T | null {
74
+ try {
75
+ const [, payload] = token.split(".");
76
+ if (!payload) return null;
77
+ const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
78
+ return JSON.parse(json) as T;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Successful JSON/binary response from {@link apiFetch}, {@link apiFetchBinary},
86
+ * or {@link publicFetch}.
87
+ */
88
+ export interface ApiSuccess<T> {
89
+ ok: true;
90
+ /** HTTP status (typically 200). */
91
+ status: number;
92
+ /** Parsed JSON (`apiFetch` / `publicFetch`) or bytes (`apiFetchBinary`). */
93
+ data: T;
94
+ }
95
+
96
+ /**
97
+ * Failed request. Errors are **returned**, never thrown.
98
+ *
99
+ * Show `error` to a person. Log `detail` to the console only — never put
100
+ * `detail` in an action return value; Foldspace may quote that to the user.
101
+ *
102
+ * `unreachable` is true when the browser never got a response (DNS, CORS,
103
+ * offline, connection refused). Timeouts set `unreachable: false` and
104
+ * `error: "Request timed out."`. Empty `API_BASE` / `AUTH_SOURCE` also
105
+ * return `{ ok: false }` with status 0 — they do not throw.
106
+ */
107
+ export interface ApiFailure {
108
+ ok: false;
109
+ /** HTTP status, or `0` when there was no response / config was missing. */
110
+ status: number;
111
+ /** Safe, user-facing sentence. Pass this to `renderError`. */
112
+ error: string;
113
+ /** Truncated API body for the console. Never show this in the widget. */
114
+ detail?: string;
115
+ /**
116
+ * True when the network itself failed (not HTTP 4xx/5xx, not abort).
117
+ * Use this to distinguish "server said no" from "could not reach host".
118
+ */
119
+ unreachable?: boolean;
120
+ }
121
+
122
+ /** Discriminated result of a customer-API call. Branch on `ok`. */
123
+ export type ApiResult<T> = ApiSuccess<T> | ApiFailure;
124
+
125
+ function missingConfigResult(): ApiResult<never> | null {
126
+ const config = getConfig();
127
+ if (!config.apiBase) {
128
+ return {
129
+ ok: false,
130
+ status: 0,
131
+ error: "API_BASE is not set — capture the app's XHR first.",
132
+ };
133
+ }
134
+ if (!config.authSource) {
135
+ return {
136
+ ok: false,
137
+ status: 0,
138
+ error: "AUTH_SOURCE is not set — capture the app's session first.",
139
+ };
140
+ }
141
+ return null;
142
+ }
143
+
144
+ function failureDetail(parsed: unknown): string | undefined {
145
+ if (parsed && typeof parsed === "object") {
146
+ const m = (parsed as { message?: unknown }).message;
147
+ if (typeof m === "string") return m.slice(0, 300);
148
+ } else if (typeof parsed === "string" && parsed.trim()) {
149
+ return parsed.trim().slice(0, 300);
150
+ }
151
+ return undefined;
152
+ }
153
+
154
+ /**
155
+ * Authenticated JSON request against `apiBase` with `Authorization: Bearer`.
156
+ *
157
+ * Matches the common pattern (two of three early builds). If the live page
158
+ * uses cookies, custom headers, or no Bearer, **stop re-exporting this** from
159
+ * `agent/utils.ts` and write a local `apiFetch` instead.
160
+ *
161
+ * Empty `API_BASE` / `AUTH_SOURCE` return `{ ok: false }` so a guessed host
162
+ * cannot look like success. Errors never throw and never include the token.
163
+ *
164
+ * @param path - Path under `apiBase`, including a leading `/`
165
+ * @param init - Extra `fetch` options; your headers merge over Bearer/JSON
166
+ * @param timeoutMs - Override the configured timeout (default 15s)
167
+ */
168
+ export async function apiFetch<T = unknown>(
169
+ path: string,
170
+ init: RequestInit = {},
171
+ timeoutMs?: number,
172
+ ): Promise<ApiResult<T>> {
173
+ const missing = missingConfigResult();
174
+ if (missing) return missing;
175
+
176
+ const token = getAuthToken();
177
+ if (!token) return { ok: false, status: 401, error: "Not signed in." };
178
+
179
+ const controller = new AbortController();
180
+ const timer = setTimeout(
181
+ () => controller.abort(),
182
+ timeoutMs ?? getConfig().timeoutMs ?? 15_000,
183
+ );
184
+
185
+ try {
186
+ const res = await fetch(`${getConfig().apiBase}${path}`, {
187
+ ...init,
188
+ signal: controller.signal,
189
+ headers: {
190
+ "Content-Type": "application/json",
191
+ Authorization: `Bearer ${token}`,
192
+ ...(init.headers ?? {}),
193
+ },
194
+ });
195
+
196
+ const text = await res.text();
197
+ let parsed: unknown = text;
198
+ try {
199
+ parsed = JSON.parse(text);
200
+ } catch {
201
+ /* non-JSON; keep the raw text for the status check */
202
+ }
203
+
204
+ if (!res.ok) {
205
+ return {
206
+ ok: false,
207
+ status: res.status,
208
+ error:
209
+ res.status === 401
210
+ ? "Your session expired. Sign in again."
211
+ : "That request could not be completed.",
212
+ detail: failureDetail(parsed),
213
+ };
214
+ }
215
+
216
+ return { ok: true, status: res.status, data: parsed as T };
217
+ } catch (error) {
218
+ const aborted = error instanceof Error && error.name === "AbortError";
219
+ return {
220
+ ok: false,
221
+ status: 0,
222
+ unreachable: !aborted,
223
+ error: aborted ? "Request timed out." : "Could not reach the server.",
224
+ };
225
+ } finally {
226
+ clearTimeout(timer);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Run an async worker over a list with a cap on in-flight work.
232
+ *
233
+ * Use this for N+1 fetches after a list call (export, hydrate rows). A large
234
+ * list without a cap can open dozens of sockets at once.
235
+ *
236
+ * @param items - Inputs to process
237
+ * @param limit - Max concurrent `worker` calls
238
+ * @param worker - Async mapper; results stay in input order
239
+ */
240
+ export async function mapWithConcurrency<T, R>(
241
+ items: T[],
242
+ limit: number,
243
+ worker: (item: T, index: number) => Promise<R>,
244
+ ): Promise<R[]> {
245
+ const results: R[] = new Array(items.length);
246
+ let cursor = 0;
247
+ const runners = Array.from(
248
+ { length: Math.min(limit, items.length) || 0 },
249
+ async () => {
250
+ while (cursor < items.length) {
251
+ const i = cursor++;
252
+ results[i] = await worker(items[i], i);
253
+ }
254
+ },
255
+ );
256
+ await Promise.all(runners);
257
+ return results;
258
+ }
259
+
260
+ /**
261
+ * Same auth and `apiBase` as {@link apiFetch}, but returns raw bytes.
262
+ *
263
+ * Use for file downloads / exports, not JSON endpoints.
264
+ *
265
+ * @param path - Path under `apiBase`, including a leading `/`
266
+ * @param init - Extra `fetch` options
267
+ * @param timeoutMs - Override the configured timeout (default 15s)
268
+ */
269
+ export async function apiFetchBinary(
270
+ path: string,
271
+ init: RequestInit = {},
272
+ timeoutMs?: number,
273
+ ): Promise<ApiResult<Uint8Array>> {
274
+ const missing = missingConfigResult();
275
+ if (missing) return missing;
276
+
277
+ const token = getAuthToken();
278
+ if (!token) return { ok: false, status: 401, error: "Not signed in." };
279
+
280
+ const controller = new AbortController();
281
+ const timer = setTimeout(
282
+ () => controller.abort(),
283
+ timeoutMs ?? getConfig().timeoutMs ?? 15_000,
284
+ );
285
+
286
+ try {
287
+ const res = await fetch(`${getConfig().apiBase}${path}`, {
288
+ ...init,
289
+ signal: controller.signal,
290
+ headers: { Authorization: `Bearer ${token}`, ...(init.headers ?? {}) },
291
+ });
292
+ if (!res.ok) {
293
+ return {
294
+ ok: false,
295
+ status: res.status,
296
+ error:
297
+ res.status === 401
298
+ ? "Your session expired. Sign in again."
299
+ : "That file could not be returned.",
300
+ };
301
+ }
302
+ return {
303
+ ok: true,
304
+ status: res.status,
305
+ data: new Uint8Array(await res.arrayBuffer()),
306
+ };
307
+ } catch (error) {
308
+ const aborted = error instanceof Error && error.name === "AbortError";
309
+ return {
310
+ ok: false,
311
+ status: 0,
312
+ unreachable: !aborted,
313
+ error: aborted ? "Download timed out." : "Could not reach the server.",
314
+ };
315
+ } finally {
316
+ clearTimeout(timer);
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Unauthenticated JSON GET. No Bearer token.
322
+ *
323
+ * Origin is the second argument, or `RuntimeConfig.publicOrigin`. Path first
324
+ * (Transparency Catalog calling convention). Use for marketing / public APIs,
325
+ * not the signed-in product API.
326
+ *
327
+ * @param path - Path under the public origin
328
+ * @param origin - Override `publicOrigin` for this call
329
+ * @param timeoutMs - Override the configured timeout (default 15s)
330
+ */
331
+ export async function publicFetch<T = unknown>(
332
+ path: string,
333
+ origin?: string,
334
+ timeoutMs?: number,
335
+ ): Promise<ApiResult<T>> {
336
+ const base = origin ?? getConfig().publicOrigin;
337
+ if (!base) {
338
+ return {
339
+ ok: false,
340
+ status: 0,
341
+ error: "No public origin is configured for this app.",
342
+ };
343
+ }
344
+ const controller = new AbortController();
345
+ const timer = setTimeout(
346
+ () => controller.abort(),
347
+ timeoutMs ?? getConfig().timeoutMs ?? 15_000,
348
+ );
349
+ try {
350
+ const res = await fetch(`${base}${path}`, {
351
+ credentials: "include",
352
+ signal: controller.signal,
353
+ });
354
+ if (!res.ok) {
355
+ return {
356
+ ok: false,
357
+ status: res.status,
358
+ error: "That request could not be completed.",
359
+ };
360
+ }
361
+ return { ok: true, status: res.status, data: (await res.json()) as T };
362
+ } catch (error) {
363
+ const aborted = error instanceof Error && error.name === "AbortError";
364
+ return {
365
+ ok: false,
366
+ status: 0,
367
+ unreachable: !aborted,
368
+ error: aborted ? "The request timed out." : "Could not reach that host.",
369
+ };
370
+ } finally {
371
+ clearTimeout(timer);
372
+ }
373
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Page-runtime helpers compiled into the customer's bundle via esbuild.
3
+ *
4
+ * Source is TypeScript on purpose: what you read here is what ships. Import
5
+ * these from `agent/utils.ts` in the client repo, not from this package path
6
+ * inside an action file.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+
11
+ export { configure, getConfig } from "./config";
12
+ export type { RuntimeConfig, AuthSource } from "./config";
13
+
14
+ export { getAgent, armAllInstances } from "./agent";
15
+
16
+ export {
17
+ redact,
18
+ getAuthToken,
19
+ parseJwt,
20
+ apiFetch,
21
+ apiFetchBinary,
22
+ publicFetch,
23
+ mapWithConcurrency,
24
+ } from "./http";
25
+ export type { ApiResult, ApiSuccess, ApiFailure } from "./http";
26
+
27
+ export { rankBy } from "./match";
28
+ export type { FuzzyMatch, RankByOptions } from "./match";
29
+
30
+ export {
31
+ renderLoading,
32
+ renderEmpty,
33
+ renderError,
34
+ renderFatal,
35
+ } from "./render";
36
+ export type { ViewHost } from "./render";
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Rank an in-memory list against what a user typed.
3
+ *
4
+ * Substring hits first (they meant that name), then the Foldspace SDK's
5
+ * `searchInList` (Fuse) for typos. Do not use this for domain ranking
6
+ * (invoices, coverage, MasterFormat).
7
+ */
8
+
9
+ import { getAgent } from "./agent";
10
+
11
+ /** One SDK fuzzy hit. `score` is lower-is-better Fuse scoring. */
12
+ export interface FuzzyMatch<T> {
13
+ item: T;
14
+ score?: number;
15
+ }
16
+
17
+ /**
18
+ * Options for {@link rankBy}.
19
+ */
20
+ export interface RankByOptions<T> {
21
+ /** Fields to match, in priority order. */
22
+ searchKeys: (keyof T & string)[];
23
+ /** Stable identity so substring and fuzzy hits can be merged. */
24
+ idOf: (item: T) => string | number;
25
+ /** Max rows to return. Default 10. */
26
+ limit?: number;
27
+ /**
28
+ * Drop SDK hits with score at or above this. Default 0.45. Lower is a
29
+ * closer Fuse match.
30
+ */
31
+ plausible?: number;
32
+ }
33
+
34
+ /** Lower is better in the SDK's scoring. Above this, a hit is noise. */
35
+ const PLAUSIBLE = 0.45;
36
+
37
+ /**
38
+ * Rank records against a user's phrase.
39
+ *
40
+ * If `searchInList` is missing, the substring pass still answers — a missing
41
+ * SDK method degrades the result rather than breaking the action.
42
+ *
43
+ * @param items - Records already loaded (e.g. from {@link apiFetch})
44
+ * @param query - What the user typed
45
+ * @param opts - Fields, identity, optional limit
46
+ * @returns Up to `limit` items, substring matches first
47
+ */
48
+ export function rankBy<T>(
49
+ items: T[],
50
+ query: string,
51
+ opts: RankByOptions<T>,
52
+ ): T[] {
53
+ const limit = opts.limit ?? 10;
54
+ const q = query.trim();
55
+ if (!q) return items.slice(0, limit);
56
+
57
+ const needle = q.toLowerCase();
58
+ const substring = items.filter((item) =>
59
+ opts.searchKeys.some((k) =>
60
+ String(item[k] ?? "")
61
+ .toLowerCase()
62
+ .includes(needle),
63
+ ),
64
+ );
65
+
66
+ let fuzzy: T[] = [];
67
+ try {
68
+ const hits: FuzzyMatch<T>[] =
69
+ getAgent()?.searchInList?.(items, q, { searchKeys: opts.searchKeys }) ??
70
+ [];
71
+ fuzzy = hits
72
+ .filter((h) => (h.score ?? 1) < (opts.plausible ?? PLAUSIBLE))
73
+ .map((h) => h.item);
74
+ } catch (err) {
75
+ console.warn("[foldspace] fuzzy search unavailable:", err);
76
+ }
77
+
78
+ const seen = new Set(substring.map(opts.idOf));
79
+ const merged = [...substring];
80
+ for (const item of fuzzy) {
81
+ const id = opts.idOf(item);
82
+ if (seen.has(id)) continue;
83
+ seen.add(id);
84
+ merged.push(item);
85
+ }
86
+ return merged.slice(0, limit);
87
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Unstyled chatterblock states. Tenant brand belongs in `agent/views/`.
3
+ *
4
+ * An action's `render` output cannot be typechecked. Look at a frame. Never
5
+ * use `innerHTML` with API data.
6
+ */
7
+
8
+ /**
9
+ * The `host` argument Foldspace passes to `render`, or `{ container }` from
10
+ * older tenant helpers. Either works.
11
+ */
12
+ export type ViewHost = HTMLElement | { container: HTMLElement };
13
+
14
+ function containerOf(host: ViewHost): HTMLElement {
15
+ return host instanceof HTMLElement ? host : host.container;
16
+ }
17
+
18
+ function el(tag: string, className: string, text?: string): HTMLElement {
19
+ const node = document.createElement(tag);
20
+ node.className = className;
21
+ if (text !== undefined) node.textContent = text;
22
+ return node;
23
+ }
24
+
25
+ function replace(host: ViewHost, node: HTMLElement): void {
26
+ const container = containerOf(host);
27
+ container.textContent = "";
28
+ container.appendChild(node);
29
+ }
30
+
31
+ /**
32
+ * In-progress chatterblock. Unstyled scaffolding — replace with branded UI
33
+ * when the card is the experience.
34
+ *
35
+ * @param host - Foldspace `render` host
36
+ * @param message - Short status text. Default `"Working..."`.
37
+ */
38
+ export function renderLoading(host: ViewHost, message = "Working..."): void {
39
+ replace(host, el("div", "fs-state fs-loading", message));
40
+ }
41
+
42
+ /**
43
+ * Nothing matched, but nothing went wrong. Say what was searched for — an empty
44
+ * panel that does not name the query reads as a failure.
45
+ *
46
+ * @param host - Foldspace `render` host
47
+ * @param message - Include the query, e.g. `No estimates matching "Webb"`
48
+ */
49
+ export function renderEmpty(host: ViewHost, message: string): void {
50
+ replace(host, el("div", "fs-state fs-empty", message));
51
+ }
52
+
53
+ /**
54
+ * Recoverable error: the user can retry or narrow. Pass `ApiResult.error`,
55
+ * never `detail` — `detail` is the API's own words and is for the console.
56
+ *
57
+ * @param host - Foldspace `render` host
58
+ * @param message - Safe, user-facing sentence
59
+ * @param onRetry - Optional click handler for a "Try again" button
60
+ */
61
+ export function renderError(
62
+ host: ViewHost,
63
+ message: string,
64
+ onRetry?: () => void,
65
+ ): void {
66
+ const wrap = el("div", "fs-state fs-error");
67
+ wrap.appendChild(el("p", "fs-error-message", message));
68
+ if (onRetry) {
69
+ const btn = el("button", "fs-retry", "Try again") as HTMLButtonElement;
70
+ btn.addEventListener("click", onRetry);
71
+ wrap.appendChild(btn);
72
+ }
73
+ replace(host, wrap);
74
+ }
75
+
76
+ /**
77
+ * Unrecoverable: retrying cannot help (signed out, host refused). Optional
78
+ * same-tab link — do not open a second tab out of the host app.
79
+ *
80
+ * @param host - Foldspace `render` host
81
+ * @param message - Safe, user-facing sentence
82
+ * @param action - Optional `{ label, href }` rendered as a same-tab `<a>`
83
+ */
84
+ export function renderFatal(
85
+ host: ViewHost,
86
+ message: string,
87
+ action?: { label: string; href: string },
88
+ ): void {
89
+ const wrap = el("div", "fs-state fs-fatal");
90
+ wrap.appendChild(el("p", "fs-error-message", message));
91
+ if (action) {
92
+ const a = el("a", "fs-fatal-action", action.label) as HTMLAnchorElement;
93
+ a.href = action.href;
94
+ wrap.appendChild(a);
95
+ }
96
+ replace(host, wrap);
97
+ }