@foldspace_npm/harness 0.1.11 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +271 -178
- package/README.md +1 -1
- package/package.json +2 -1
- package/recipes/INDEX.md +15 -0
- package/recipes/README.md +46 -0
- package/recipes/find-by-name/README.md +47 -0
- package/recipes/find-by-name/agent/actions/find_project_id.ts +101 -0
- package/recipes/find-by-name/agent/api/projects.ts +36 -0
- package/recipes/find-by-name/agent/projects.ts +40 -0
- package/recipes/find-by-name/fixtures/projects.all.json +29 -0
- package/recipes/find-by-name/fixtures/projects.empty-account.json +4 -0
- package/recipes/find-by-name/fixtures/projects.none.json +4 -0
- package/recipes/find-by-name/recipe.json +10 -0
- package/recipes/pick-from-a-list/README.md +44 -0
- package/recipes/pick-from-a-list/agent/actions/choose_project.ts +161 -0
- package/recipes/pick-from-a-list/agent/api/projects.ts +36 -0
- package/recipes/pick-from-a-list/agent/projects.ts +40 -0
- package/recipes/pick-from-a-list/agent/views/brand.ts +14 -0
- package/recipes/pick-from-a-list/agent/views/picker.ts +119 -0
- package/recipes/pick-from-a-list/fixtures/projects.all.json +29 -0
- package/recipes/pick-from-a-list/fixtures/projects.empty-account.json +4 -0
- package/recipes/pick-from-a-list/recipe.json +10 -0
- package/recipes/swap-the-login-method/README.md +40 -0
- package/recipes/swap-the-login-method/agent/utils.ts +73 -0
- package/recipes/swap-the-login-method/fixtures/anything.ok.json +8 -0
- package/recipes/swap-the-login-method/recipe.json +12 -0
- package/recipes/swap-the-login-method/variants/utils.cookies.ts +64 -0
- package/recipes/who-is-the-user/README.md +56 -0
- package/recipes/who-is-the-user/agent/identify.ts +89 -0
- package/recipes/who-is-the-user/fixtures/profile.ok.json +6 -0
- package/recipes/who-is-the-user/recipe.json +9 -0
- package/src/runtime/config.ts +1 -1
- package/src/runtime/http.ts +104 -53
- package/src/runtime/index.ts +4 -1
- package/src/runtime/match.ts +1 -1
- package/src/runtime/render.ts +42 -1
- package/templates/agent-starter/agent/actions/_example.ts +9 -2
- package/templates/agent-starter/agent/utils.ts +2 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// A clickable list. textContent only — never innerHTML with API data.
|
|
2
|
+
|
|
3
|
+
import type { ProjectSummary } from "../projects";
|
|
4
|
+
import { brand } from "./brand";
|
|
5
|
+
|
|
6
|
+
export type PickerChoice =
|
|
7
|
+
| { kind: "project"; project: ProjectSummary }
|
|
8
|
+
| { kind: "other" }
|
|
9
|
+
| { kind: "cancel" };
|
|
10
|
+
|
|
11
|
+
export type PickerOptions = {
|
|
12
|
+
title: string;
|
|
13
|
+
subtitle: string;
|
|
14
|
+
projects: ProjectSummary[];
|
|
15
|
+
onSubmit: (choice: PickerChoice, offered: ProjectSummary[]) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const CSS = `
|
|
19
|
+
.fs-pick{font-family:${brand.font};font-size:${brand.size.base};color:${brand.text};
|
|
20
|
+
background:${brand.surface};border:1px solid ${brand.border};border-radius:${brand.radius};overflow:hidden;box-sizing:border-box}
|
|
21
|
+
.fs-pick *{box-sizing:border-box;margin:0}
|
|
22
|
+
.fs-pick-head{background:${brand.tint};padding:12px 16px}
|
|
23
|
+
.fs-pick-title{font-weight:600}
|
|
24
|
+
.fs-pick-sub{font-size:${brand.size.small};color:${brand.muted};margin-top:2px}
|
|
25
|
+
.fs-pick-row{display:block;width:100%;text-align:left;padding:10px 16px;border:0;border-top:1px solid ${brand.border};
|
|
26
|
+
background:${brand.surface};font:inherit;color:inherit;cursor:pointer}
|
|
27
|
+
.fs-pick-row.is-selected{background:${brand.tint};box-shadow:inset 3px 0 0 ${brand.primary}}
|
|
28
|
+
.fs-pick-meta{display:block;font-size:${brand.size.small};color:${brand.muted}}
|
|
29
|
+
.fs-pick-foot{display:flex;justify-content:flex-end;gap:8px;padding:10px 16px;border-top:1px solid ${brand.border}}
|
|
30
|
+
.fs-pick-btn{font:inherit;padding:6px 14px;border-radius:${brand.radius};border:1px solid ${brand.border};background:${brand.surface};cursor:pointer}
|
|
31
|
+
.fs-pick-submit{background:${brand.primary};border-color:${brand.primary};color:${brand.surface}}
|
|
32
|
+
.fs-pick-submit:disabled{opacity:.5;cursor:default}
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
function el(tag: string, className: string, text?: string): HTMLElement {
|
|
36
|
+
const node = document.createElement(tag);
|
|
37
|
+
node.className = className;
|
|
38
|
+
if (text !== undefined) node.textContent = text;
|
|
39
|
+
return node;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function button(className: string, text: string): HTMLButtonElement {
|
|
43
|
+
const node = el("button", className, text) as HTMLButtonElement;
|
|
44
|
+
node.type = "button";
|
|
45
|
+
return node;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function mountProjectPicker(host: HTMLElement, header: HTMLElement, options: PickerOptions): void {
|
|
49
|
+
// `host` lives inside the component's frame; styles belong in `header`.
|
|
50
|
+
const style = el("style", "");
|
|
51
|
+
style.textContent = CSS;
|
|
52
|
+
header.append(style);
|
|
53
|
+
|
|
54
|
+
const card = el("div", "fs-pick");
|
|
55
|
+
const head = el("div", "fs-pick-head");
|
|
56
|
+
const title = el("div", "fs-pick-title", options.title);
|
|
57
|
+
const subtitle = el("div", "fs-pick-sub", options.subtitle);
|
|
58
|
+
head.append(title, subtitle);
|
|
59
|
+
card.append(head);
|
|
60
|
+
|
|
61
|
+
const submit = button("fs-pick-btn fs-pick-submit", "Submit");
|
|
62
|
+
submit.disabled = true;
|
|
63
|
+
const cancel = button("fs-pick-btn", "Cancel");
|
|
64
|
+
|
|
65
|
+
let choice: PickerChoice | null = null;
|
|
66
|
+
const rows: HTMLButtonElement[] = [];
|
|
67
|
+
|
|
68
|
+
const select = (row: HTMLButtonElement, next: PickerChoice) => {
|
|
69
|
+
choice = next;
|
|
70
|
+
for (const other of rows) other.className = "fs-pick-row";
|
|
71
|
+
row.className = "fs-pick-row is-selected";
|
|
72
|
+
submit.disabled = false;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
for (const project of options.projects) {
|
|
76
|
+
const row = button("fs-pick-row", project.name);
|
|
77
|
+
if (project.status) row.append(el("span", "fs-pick-meta", project.status));
|
|
78
|
+
row.addEventListener("click", () => select(row, { kind: "project", project }));
|
|
79
|
+
rows.push(row);
|
|
80
|
+
card.append(row);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const none = button("fs-pick-row", "None of these");
|
|
84
|
+
none.append(el("span", "fs-pick-meta", "Tell the assistant what you meant"));
|
|
85
|
+
none.addEventListener("click", () => select(none, { kind: "other" }));
|
|
86
|
+
rows.push(none);
|
|
87
|
+
card.append(none);
|
|
88
|
+
|
|
89
|
+
// Once answered, the card says so and stops responding — a card that still
|
|
90
|
+
// looks live after the agent has moved on gets clicked again.
|
|
91
|
+
const finish = (done: PickerChoice) => {
|
|
92
|
+
for (const row of rows) row.disabled = true;
|
|
93
|
+
submit.disabled = true;
|
|
94
|
+
cancel.disabled = true;
|
|
95
|
+
if (done.kind === "project") {
|
|
96
|
+
title.textContent = "Project selected";
|
|
97
|
+
subtitle.textContent = done.project.name;
|
|
98
|
+
} else if (done.kind === "other") {
|
|
99
|
+
title.textContent = "Sent to the assistant";
|
|
100
|
+
subtitle.textContent = "None of these projects.";
|
|
101
|
+
} else {
|
|
102
|
+
title.textContent = "Cancelled";
|
|
103
|
+
subtitle.textContent = "No project chosen.";
|
|
104
|
+
}
|
|
105
|
+
options.onSubmit(done, options.projects);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
submit.addEventListener("click", () => {
|
|
109
|
+
if (choice) finish(choice);
|
|
110
|
+
});
|
|
111
|
+
cancel.addEventListener("click", () => finish({ kind: "cancel" }));
|
|
112
|
+
|
|
113
|
+
const foot = el("div", "fs-pick-foot");
|
|
114
|
+
foot.append(cancel, submit);
|
|
115
|
+
card.append(foot);
|
|
116
|
+
|
|
117
|
+
host.textContent = "";
|
|
118
|
+
host.append(card);
|
|
119
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"items": [
|
|
3
|
+
{
|
|
4
|
+
"id": "p_101",
|
|
5
|
+
"name": "Maple Street",
|
|
6
|
+
"status": "Active",
|
|
7
|
+
"updatedAt": "2026-08-30T14:05:00Z"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"id": "p_102",
|
|
11
|
+
"name": "Harbor Street Office",
|
|
12
|
+
"status": "Draft",
|
|
13
|
+
"updatedAt": "2026-07-12T09:00:00Z"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "p_103",
|
|
17
|
+
"name": "Maple Street Annex",
|
|
18
|
+
"status": "Closed",
|
|
19
|
+
"updatedAt": "2026-03-02T17:40:00Z"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "p_104",
|
|
23
|
+
"name": "Harbor View",
|
|
24
|
+
"status": "Active",
|
|
25
|
+
"updatedAt": "2026-02-11T10:00:00Z"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"total": 4
|
|
29
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Pick from a list",
|
|
3
|
+
"level": "L2",
|
|
4
|
+
"family": "ask-the-user",
|
|
5
|
+
"kind": "action",
|
|
6
|
+
"action": "choose_project",
|
|
7
|
+
"entry": "agent/actions/choose_project.ts",
|
|
8
|
+
"outcome": "A clickable list, shown only when the user has to choose",
|
|
9
|
+
"provenBy": 1
|
|
10
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Swap the login method
|
|
2
|
+
|
|
3
|
+
The harness's `apiFetch` sends `Authorization: Bearer <token>`. That fits some
|
|
4
|
+
apps. **Three production builds, three different schemes:**
|
|
5
|
+
|
|
6
|
+
| What the app's own requests carried | Builds | Use |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| `Authorization: Bearer`, token in a cookie or `localStorage`, plus a fixed API-key header | 1 | the default `apiFetch`, with the extra header passed in `init.headers` |
|
|
9
|
+
| A **custom header** holding a session id read from `localStorage` | 1 | `agent/utils.ts` here |
|
|
10
|
+
| **Cookies** — `credentials: "include"` — and no token at all | 1 | `variants/utils.cookies.ts` |
|
|
11
|
+
|
|
12
|
+
**Observe which one the app uses before choosing.** Read it off a request the
|
|
13
|
+
page already made. Never guess, and never reach for a public developer API
|
|
14
|
+
because the browser session was missing.
|
|
15
|
+
|
|
16
|
+
## Adapt it
|
|
17
|
+
|
|
18
|
+
Copy the variant you need over your project's `agent/utils.ts`. Actions keep
|
|
19
|
+
importing from `../utils`, so nothing else changes — that is what the seam is
|
|
20
|
+
for.
|
|
21
|
+
|
|
22
|
+
| In the file | Change |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `SESSION_STORAGE_KEY`, `SESSION_HEADER` | The key and header name you **observed** |
|
|
25
|
+
| The cookies variant's extra headers | Only what you observed the app sending. One build needed `x-requested-with`; most do not |
|
|
26
|
+
| `API_BASE` in `constants.ts` | For a same-origin app, the app's own origin. Empty stays a failure on purpose, so a guessed host cannot look like success |
|
|
27
|
+
|
|
28
|
+
## What those builds learned the hard way
|
|
29
|
+
|
|
30
|
+
- **Keep the failure reasons.** Replace the transport, not what a 401 or a 404
|
|
31
|
+
means: `httpFailure` and `networkFailure` are exported for exactly this, so a
|
|
32
|
+
custom `apiFetch` still returns `signed_out`, `not_found`, `rate_limited`.
|
|
33
|
+
Hand-rolled versions turned every failure into "unexpected error", or a
|
|
34
|
+
missing session into an empty list.
|
|
35
|
+
- **Tokens are stored three ways** — raw, JSON-encoded (wrapped in quotes), or
|
|
36
|
+
inside an object. Strip the quotes; one app switched between the first two.
|
|
37
|
+
- **With cookies there is no token to check first**, so "signed out" is only
|
|
38
|
+
known from the 401. Let it through as `signed_out` instead of pre-empting it.
|
|
39
|
+
- **Do not `export *`** alongside your own `apiFetch` — name what you re-export,
|
|
40
|
+
so the default cannot come back by accident.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// agent/utils.ts for an app that authenticates with a CUSTOM HEADER holding a
|
|
2
|
+
// session id from localStorage — not `Authorization: Bearer`.
|
|
3
|
+
//
|
|
4
|
+
// Only the transport is replaced. What a 401, a 404 or a 429 means still comes
|
|
5
|
+
// from the harness, so actions and renderFailure behave exactly the same.
|
|
6
|
+
|
|
7
|
+
import { configure, httpFailure, networkFailure, type ApiResult } from "@foldspace_npm/harness/runtime";
|
|
8
|
+
import { AGENT_API_NAME, API_BASE } from "./constants";
|
|
9
|
+
|
|
10
|
+
configure({ agentApiName: AGENT_API_NAME, apiBase: API_BASE });
|
|
11
|
+
|
|
12
|
+
/** The localStorage key and the header name you OBSERVED on a real request. */
|
|
13
|
+
const SESSION_STORAGE_KEY = "__observe_me_session";
|
|
14
|
+
const SESSION_HEADER = "__observe-me-session";
|
|
15
|
+
|
|
16
|
+
function readSession(): string | null {
|
|
17
|
+
const raw = window.localStorage.getItem(SESSION_STORAGE_KEY);
|
|
18
|
+
// Apps store it raw or JSON-encoded; strip the quotes either way.
|
|
19
|
+
return raw ? raw.replace(/^"|"$/g, "") || null : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function apiFetch<T = unknown>(
|
|
23
|
+
path: string,
|
|
24
|
+
init: RequestInit = {},
|
|
25
|
+
timeoutMs = 15_000,
|
|
26
|
+
): Promise<ApiResult<T>> {
|
|
27
|
+
if (!API_BASE) {
|
|
28
|
+
return { ok: false, status: 0, reason: "config", error: "API_BASE is not set — capture the app's XHR first." };
|
|
29
|
+
}
|
|
30
|
+
const session = readSession();
|
|
31
|
+
if (!session) return { ok: false, status: 401, reason: "signed_out", error: "Not signed in." };
|
|
32
|
+
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
37
|
+
...init,
|
|
38
|
+
signal: controller.signal,
|
|
39
|
+
headers: { "Content-Type": "application/json", [SESSION_HEADER]: session, ...(init.headers ?? {}) },
|
|
40
|
+
});
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
let parsed: unknown = text;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(text);
|
|
45
|
+
} catch {
|
|
46
|
+
/* non-JSON; keep the raw text */
|
|
47
|
+
}
|
|
48
|
+
if (!res.ok) return httpFailure(res, "That request could not be completed.");
|
|
49
|
+
return { ok: true, status: res.status, data: parsed as T };
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return networkFailure(error, "Request timed out.", "Could not reach the server.");
|
|
52
|
+
} finally {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Everything else still comes from the harness. Named, NOT `export *` — that
|
|
58
|
+
// would put the default apiFetch back next to this one.
|
|
59
|
+
export {
|
|
60
|
+
getAgent,
|
|
61
|
+
armAllInstances,
|
|
62
|
+
rankBy,
|
|
63
|
+
redact,
|
|
64
|
+
parseJwt,
|
|
65
|
+
publicFetch,
|
|
66
|
+
mapWithConcurrency,
|
|
67
|
+
renderLoading,
|
|
68
|
+
renderEmpty,
|
|
69
|
+
renderError,
|
|
70
|
+
renderFatal,
|
|
71
|
+
renderFailure,
|
|
72
|
+
} from "@foldspace_npm/harness/runtime";
|
|
73
|
+
export type { ApiResult, ApiFailure, FailureReason, ViewHost } from "@foldspace_npm/harness/runtime";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Swap the login method",
|
|
3
|
+
"level": "any",
|
|
4
|
+
"family": "auth",
|
|
5
|
+
"kind": "override",
|
|
6
|
+
"entry": "agent/utils.ts",
|
|
7
|
+
"variants": [
|
|
8
|
+
"variants/utils.cookies.ts"
|
|
9
|
+
],
|
|
10
|
+
"outcome": "apiFetch for an app that does not authenticate with a bearer token",
|
|
11
|
+
"provenBy": 2
|
|
12
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// agent/utils.ts for an app that authenticates with COOKIES — no token to read.
|
|
2
|
+
// Copy this over agent/utils.ts.
|
|
3
|
+
//
|
|
4
|
+
// There is nothing to check before the request, so "signed out" is only known
|
|
5
|
+
// from the 401. It comes back as `signed_out` like any other.
|
|
6
|
+
|
|
7
|
+
import { configure, httpFailure, networkFailure, type ApiResult } from "@foldspace_npm/harness/runtime";
|
|
8
|
+
import { AGENT_API_NAME, API_BASE } from "./constants";
|
|
9
|
+
|
|
10
|
+
configure({ agentApiName: AGENT_API_NAME, apiBase: API_BASE });
|
|
11
|
+
|
|
12
|
+
export async function apiFetch<T = unknown>(
|
|
13
|
+
path: string,
|
|
14
|
+
init: RequestInit = {},
|
|
15
|
+
timeoutMs = 15_000,
|
|
16
|
+
): Promise<ApiResult<T>> {
|
|
17
|
+
if (!API_BASE) {
|
|
18
|
+
return { ok: false, status: 0, reason: "config", error: "API_BASE is not set — capture the app's XHR first." };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
25
|
+
...init,
|
|
26
|
+
credentials: "include",
|
|
27
|
+
signal: controller.signal,
|
|
28
|
+
// Add only what you OBSERVED the app sending. One build needed
|
|
29
|
+
// `x-requested-with: XMLHttpRequest`; most do not.
|
|
30
|
+
headers: { accept: "application/json", "Content-Type": "application/json", ...(init.headers ?? {}) },
|
|
31
|
+
});
|
|
32
|
+
const text = await res.text();
|
|
33
|
+
let parsed: unknown = text;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(text);
|
|
36
|
+
} catch {
|
|
37
|
+
/* non-JSON; keep the raw text */
|
|
38
|
+
}
|
|
39
|
+
if (!res.ok) return httpFailure(res, "That request could not be completed.");
|
|
40
|
+
return { ok: true, status: res.status, data: parsed as T };
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return networkFailure(error, "Request timed out.", "Could not reach the server.");
|
|
43
|
+
} finally {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Everything else still comes from the harness. Named, NOT `export *` — that
|
|
49
|
+
// would put the default apiFetch back next to this one.
|
|
50
|
+
export {
|
|
51
|
+
getAgent,
|
|
52
|
+
armAllInstances,
|
|
53
|
+
rankBy,
|
|
54
|
+
redact,
|
|
55
|
+
parseJwt,
|
|
56
|
+
publicFetch,
|
|
57
|
+
mapWithConcurrency,
|
|
58
|
+
renderLoading,
|
|
59
|
+
renderEmpty,
|
|
60
|
+
renderError,
|
|
61
|
+
renderFatal,
|
|
62
|
+
renderFailure,
|
|
63
|
+
} from "@foldspace_npm/harness/runtime";
|
|
64
|
+
export type { ApiResult, ApiFailure, FailureReason, ViewHost } from "@foldspace_npm/harness/runtime";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Who is the user — L0
|
|
2
|
+
|
|
3
|
+
L0 is not "the agent appears". It is the agent appearing **and** Foldspace
|
|
4
|
+
knowing who it is talking to. Without `identify`, every conversation, every
|
|
5
|
+
analytics row and every segment is anonymous.
|
|
6
|
+
|
|
7
|
+
**Proven by 4 production builds**, which found the identity in three different
|
|
8
|
+
places — so look before you assume:
|
|
9
|
+
|
|
10
|
+
| Where the signed-in user was found | Builds |
|
|
11
|
+
|---|---|
|
|
12
|
+
| Claims inside the app's own session token (user id, email, organisation id) | 2 |
|
|
13
|
+
| A user object the app keeps in `localStorage` | 1 |
|
|
14
|
+
| A call to the app's own account endpoint, cached for the page | 1 |
|
|
15
|
+
|
|
16
|
+
## Adapt it
|
|
17
|
+
|
|
18
|
+
| In `agent/identify.ts` | Change |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `SessionClaims` | The claim names you **observed** in the token. Two builds, two spellings |
|
|
21
|
+
| `/__observe_me/profile` and `Profile` | The endpoint that describes **the signed-in user** — see the trap below |
|
|
22
|
+
| `subscription.id` | Whatever groups colleagues: organisation, account, workspace |
|
|
23
|
+
|
|
24
|
+
Then call `identifyUser()` from the bundle's entry point:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// agent/actions/index.ts — last line
|
|
28
|
+
import { identifyUser } from "../identify";
|
|
29
|
+
identifyUser();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Custom attributes (a plan name, a sign-up date) only land if they were created
|
|
33
|
+
first in **Settings → Attribute Settings**; send them under their API Name.
|
|
34
|
+
|
|
35
|
+
## What those builds learned the hard way
|
|
36
|
+
|
|
37
|
+
- **Claim the "already done" flag before the first `await`.** Checking and then
|
|
38
|
+
awaiting lets every concurrent load past the check — three bundle loads
|
|
39
|
+
produced three `identify` calls.
|
|
40
|
+
- **Keep the flag on `window`, not in module scope.** A bundle evaluated twice
|
|
41
|
+
gets two module scopes and identifies twice.
|
|
42
|
+
- **An account endpoint may describe the account, not the person.** One returned
|
|
43
|
+
the billing contact's name and email for whoever was signed in — which labels
|
|
44
|
+
every colleague as the same person. Read the person from the session; read
|
|
45
|
+
only the account id from the account.
|
|
46
|
+
- **No id means stay anonymous.** A wrong id silently merges or splits real
|
|
47
|
+
people in the analytics. Never invent one.
|
|
48
|
+
- **A failed identify must not break the agent.** Every failure is silent to the
|
|
49
|
+
user, and releases the flag so a later load can try again.
|
|
50
|
+
- **Hide the agent until identify resolves, with a timeout** — and on a page
|
|
51
|
+
with no signed-in user (the login page), do not show it at all.
|
|
52
|
+
- **This is unsigned.** Anyone can call `foldspace.identify` from a console.
|
|
53
|
+
Signing has to come from the customer's backend
|
|
54
|
+
(<https://docs.foldspace.ai/security/overview/>); until then treat the
|
|
55
|
+
analytics as indicative. And if the customer's own page ever calls
|
|
56
|
+
`identify`, delete this file — theirs runs earlier and knows more.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Tell Foldspace who the signed-in user is — once, when the SDK is ready.
|
|
2
|
+
// Context shape: https://docs.foldspace.ai/start/user-context/
|
|
3
|
+
//
|
|
4
|
+
// UNSIGNED: anyone can call foldspace.identify from a console. Signing has to
|
|
5
|
+
// come from the customer's backend. See this recipe's README.
|
|
6
|
+
|
|
7
|
+
import { apiFetch, getAuthToken, parseJwt, redact } from "./utils";
|
|
8
|
+
|
|
9
|
+
/** Claims you OBSERVED in the app's session token. Replace these names. */
|
|
10
|
+
type SessionClaims = {
|
|
11
|
+
sub?: string | number;
|
|
12
|
+
email?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The endpoint that describes THE SIGNED-IN USER. Check it against a second
|
|
17
|
+
* account member before trusting it: one app's account endpoint returned the
|
|
18
|
+
* billing contact for everyone.
|
|
19
|
+
*/
|
|
20
|
+
type Profile = {
|
|
21
|
+
firstName?: string | null;
|
|
22
|
+
lastName?: string | null;
|
|
23
|
+
role?: string | null;
|
|
24
|
+
organizationId?: string | number | null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// On window, not in module scope: a bundle evaluated twice on one page gets two
|
|
28
|
+
// module scopes, and would identify twice.
|
|
29
|
+
const IDENTIFIED_FLAG = "__foldspace_identified__";
|
|
30
|
+
|
|
31
|
+
const claimed = (): boolean => (window as any)[IDENTIFIED_FLAG] === true;
|
|
32
|
+
const claim = (): void => void ((window as any)[IDENTIFIED_FLAG] = true);
|
|
33
|
+
const release = (): void => void ((window as any)[IDENTIFIED_FLAG] = false);
|
|
34
|
+
|
|
35
|
+
function sessionIdentity(): { id: string; email: string | null } | null {
|
|
36
|
+
const token = getAuthToken();
|
|
37
|
+
const claims = token ? parseJwt<SessionClaims>(token) : null;
|
|
38
|
+
const raw = claims?.sub;
|
|
39
|
+
const id = typeof raw === "number" ? String(raw) : typeof raw === "string" ? raw.trim() : "";
|
|
40
|
+
if (!id) return null;
|
|
41
|
+
const email = typeof claims?.email === "string" && claims.email.includes("@") ? claims.email.trim() : null;
|
|
42
|
+
return { id, email };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every failure is silent to the user: an unidentified session is a degraded record, never a broken agent. */
|
|
46
|
+
export function identifyUser(): void {
|
|
47
|
+
const foldspace = (window as any).foldspace;
|
|
48
|
+
if (typeof foldspace !== "function" || typeof foldspace.identify !== "function") return;
|
|
49
|
+
if (claimed()) return;
|
|
50
|
+
|
|
51
|
+
foldspace("when", "ready", async () => {
|
|
52
|
+
if (claimed()) return;
|
|
53
|
+
// Claimed BEFORE the first await, or every concurrent load gets past the check.
|
|
54
|
+
claim();
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// No id means anonymous. Never invent one: a wrong id merges or splits
|
|
58
|
+
// real people in the analytics.
|
|
59
|
+
const who = sessionIdentity();
|
|
60
|
+
if (!who) {
|
|
61
|
+
release();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const user: Record<string, unknown> = { id: who.id };
|
|
66
|
+
if (who.email) user.email = who.email;
|
|
67
|
+
const context: Record<string, unknown> = { user };
|
|
68
|
+
|
|
69
|
+
const profile = await apiFetch<Profile>("/__observe_me/profile");
|
|
70
|
+
if (profile.ok) {
|
|
71
|
+
const name = [profile.data?.firstName, profile.data?.lastName].filter(Boolean).join(" ");
|
|
72
|
+
if (name) user.name = name;
|
|
73
|
+
if (profile.data?.role) user.role = profile.data.role;
|
|
74
|
+
// One subscription, many users: whatever groups colleagues together.
|
|
75
|
+
const org = profile.data?.organizationId;
|
|
76
|
+
if (org !== null && org !== undefined && String(org) !== "") {
|
|
77
|
+
context.subscription = { id: String(org) };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
foldspace.identify(context);
|
|
82
|
+
// Never log the identity itself — only that it fired.
|
|
83
|
+
console.debug("[foldspace] identified", redact(who.id), user.name ? "with name" : "id only");
|
|
84
|
+
} catch (error) {
|
|
85
|
+
release();
|
|
86
|
+
console.warn("[foldspace] identify skipped:", error);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
package/src/runtime/config.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface AuthSource {
|
|
|
26
26
|
* wrong place.
|
|
27
27
|
*/
|
|
28
28
|
export interface RuntimeConfig {
|
|
29
|
-
/** Foldspace agent apiName, e.g. `
|
|
29
|
+
/** Foldspace agent apiName, e.g. `acme-agent`. */
|
|
30
30
|
agentApiName: string;
|
|
31
31
|
/**
|
|
32
32
|
* Prefix for `apiFetch` / `apiFetchBinary`. Include an explicit port if the
|