@blendification/results 0.0.0-stage → 0.1.1
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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +110 -2
- package/package.json +53 -4
- package/src/auth.js +342 -0
- package/src/client.js +242 -0
- package/src/index.js +26 -0
- package/src/webhooks.js +44 -0
- package/types/index.d.ts +167 -0
- package/types/webhooks.d.ts +26 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- `TrackedConversation`, the type of what `conversations()` yields, now has the fields the server sends: `conversationId`, `respondentName` and `respondentEmail`. 0.1.0 declared `id`, `name` and `email`, which were always undefined. The type also gains `abandoned`, `templateId` and the conversation's dates. It drops the catch-all index signature, so a misspelt field no longer compiles.
|
|
6
|
+
- `conversationPage()` returns a typed `ConversationPage`, and `status` accepts one status or a list.
|
|
7
|
+
- The README named the same wrong fields; it now names the real ones.
|
|
8
|
+
|
|
9
|
+
## 0.1.0
|
|
10
|
+
|
|
11
|
+
First release.
|
|
12
|
+
|
|
13
|
+
- `createResultsClient` calls Blendification's MCP tools and wraps the result reads: `progress`, `conversations`, `transcript`, `summary`, `exportGroup`, `waitForJob` and `watchProgress`.
|
|
14
|
+
- `pkceSignIn` signs a page's viewer in with their Blendification account (OAuth 2.1 with PKCE). `clientCredentials` is for servers, and `staticToken` is for scripts.
|
|
15
|
+
- `registerPageClient` registers a page with the sign-in service once.
|
|
16
|
+
- `@blendification/results/webhooks` exports `verifyWebhookSignature`, which checks webhook deliveries.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Blendification
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,111 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @blendification/results
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Read Blendification results from your own page or server: how a survey wave is
|
|
4
|
+
going, who has finished, transcripts, results-group summaries and full exports.
|
|
5
|
+
The package also verifies the webhooks Blendification sends when someone starts
|
|
6
|
+
or finishes a conversation.
|
|
7
|
+
|
|
8
|
+
It talks to the same MCP endpoint an AI agent uses, so a page sees what its
|
|
9
|
+
owner's agent sees, through the same tenant checks.
|
|
10
|
+
|
|
11
|
+
## Credentials
|
|
12
|
+
|
|
13
|
+
There is no API key to paste into a page.
|
|
14
|
+
|
|
15
|
+
- **A page in a browser** signs its viewer in with their Blendification account,
|
|
16
|
+
using OAuth 2.1 with PKCE. The page holds only that person's short-lived,
|
|
17
|
+
read-only token, and it renews itself.
|
|
18
|
+
- **A server** (a page's backend, a CRM sync, an agent such as go-ai) uses OAuth
|
|
19
|
+
client credentials that Blendification issues for one company. Keep the
|
|
20
|
+
secret on the server.
|
|
21
|
+
|
|
22
|
+
## A results page
|
|
23
|
+
|
|
24
|
+
**1. Register the page once.** Do this from a terminal or a script, not on every
|
|
25
|
+
page load. It needs the exact address the sign-in should come back to.
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { registerPageClient } from '@blendification/results';
|
|
29
|
+
|
|
30
|
+
const { clientId } = await registerPageClient({
|
|
31
|
+
mcpUrl: 'https://<mcp host>/mcp', // the address your MCP connection uses
|
|
32
|
+
redirectUris: ['https://your-site.example/results'],
|
|
33
|
+
clientName: 'Acme results page',
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The client id is not a secret. Put it in the page's config.
|
|
38
|
+
|
|
39
|
+
**2. Sign the viewer in, then read.**
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
import { createResultsClient, pkceSignIn } from '@blendification/results';
|
|
43
|
+
|
|
44
|
+
const auth = pkceSignIn({ clientId: '<client id>', redirectUri: 'https://your-site.example/results' });
|
|
45
|
+
const results = createResultsClient({ mcpUrl: 'https://<mcp host>/mcp', auth });
|
|
46
|
+
|
|
47
|
+
await auth.handleRedirect(); // call on every load; it ignores a URL that has no sign-in answer
|
|
48
|
+
if (!auth.isSignedIn()) {
|
|
49
|
+
showSignInButton(() => auth.signIn()); // sends the viewer to Blendification's sign-in page
|
|
50
|
+
} else {
|
|
51
|
+
const p = await results.progress({ templateId: '<template id>' });
|
|
52
|
+
render(`${p.complete} of ${p.total} finished (${p.completionRatePercent}%)`);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`auth.viewer()` gives the signed-in person's email and name, for display.
|
|
57
|
+
`auth.signOut()` forgets the page's tokens.
|
|
58
|
+
|
|
59
|
+
## A server
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
import { createResultsClient, clientCredentials } from '@blendification/results';
|
|
63
|
+
|
|
64
|
+
const results = createResultsClient({
|
|
65
|
+
mcpUrl: process.env.BLENDIFICATION_MCP_URL,
|
|
66
|
+
auth: clientCredentials({
|
|
67
|
+
clientId: process.env.BLENDIFICATION_CLIENT_ID,
|
|
68
|
+
clientSecret: process.env.BLENDIFICATION_CLIENT_SECRET,
|
|
69
|
+
scope: 'mcp/read', // or 'mcp/read mcp/write' if the client was issued both
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`clientCredentials` refuses to run in a browser.
|
|
75
|
+
|
|
76
|
+
## Reading
|
|
77
|
+
|
|
78
|
+
| Call | What it returns |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| `progress({ templateId?, inviteGroupName?, since? })` | Status counts, completion rate, and splits by invite group or template, read to the end and added up |
|
|
81
|
+
| `conversations({ templateId?, inviteGroupName?, status?, since? })` | An async iterator over every conversation: `conversationId`, `status`, `abandoned`, `respondentName`, `respondentEmail`, `inviteGroup`, `link`, and its dates (`startedAt`, `completedAt`) |
|
|
82
|
+
| `transcript(conversationId, { resultsGroupId })` | Every message, oldest first |
|
|
83
|
+
| `summary(resultsGroupId)` | A results group's name, counts, topics and reports |
|
|
84
|
+
| `exportGroup(resultsGroupId, { format: 'csv' })` | Starts an export, waits for it, and returns download links plus the first page |
|
|
85
|
+
| `watchProgress(args, { onUpdate, intervalMs })` | Polls `progress` and calls `onUpdate` when the counts change. Returns `stop()` |
|
|
86
|
+
| `call(tool, args)` | Any MCP tool the token may use |
|
|
87
|
+
|
|
88
|
+
## Webhooks
|
|
89
|
+
|
|
90
|
+
Register a webhook over MCP (`webhook_create`) to have Blendification POST to
|
|
91
|
+
your server when a conversation starts or completes. Verify every delivery
|
|
92
|
+
before trusting it. Pass the raw body, before JSON parsing.
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
import { verifyWebhookSignature } from '@blendification/results/webhooks';
|
|
96
|
+
|
|
97
|
+
app.post('/hooks/blendification', express.raw({ type: 'application/json' }), (req, res) => {
|
|
98
|
+
const ok = verifyWebhookSignature({
|
|
99
|
+
secret: process.env.BLENDIFICATION_WEBHOOK_SECRET,
|
|
100
|
+
header: req.headers['blendification-signature'],
|
|
101
|
+
body: req.body,
|
|
102
|
+
});
|
|
103
|
+
if (!ok) return res.status(400).end();
|
|
104
|
+
const event = JSON.parse(req.body); // { id, type, createdAt, data: { conversationId, templateId, ... } }
|
|
105
|
+
res.status(200).end(); // answer fast; do slow work afterwards
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The event `id` is stable. Ignore an id you have already handled. A delivery
|
|
110
|
+
that fails is retried for about 45 minutes. If 25 events in a row fail, the
|
|
111
|
+
webhook is switched off, and `webhook_list` shows why.
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blendification/results",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Read Blendification results (survey progress, conversations, transcripts, exports) from your own page or server, signed in with Blendification's OAuth, and verify the webhooks it sends.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Blendification",
|
|
7
|
+
"homepage": "https://github.com/blendification/blendification-platform/tree/main/packages/results-sdk#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/blendification/blendification-platform.git",
|
|
11
|
+
"directory": "packages/results-sdk"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/blendification/blendification-platform/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"blendification",
|
|
18
|
+
"results",
|
|
19
|
+
"survey",
|
|
20
|
+
"mcp",
|
|
21
|
+
"oauth",
|
|
22
|
+
"webhooks"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "src/index.js",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./types/index.d.ts",
|
|
29
|
+
"default": "./src/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./webhooks": {
|
|
32
|
+
"types": "./types/webhooks.d.ts",
|
|
33
|
+
"default": "./src/webhooks.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"sideEffects": false,
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"types",
|
|
40
|
+
"README.md",
|
|
41
|
+
"CHANGELOG.md"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"lint": "echo \"results: no lint configured yet\" && exit 0",
|
|
51
|
+
"test": "node --test test/*.test.js",
|
|
52
|
+
"check": "node --check src/index.js && node --check src/client.js && node --check src/auth.js && node --check src/webhooks.js"
|
|
53
|
+
},
|
|
54
|
+
"types": "types/index.d.ts"
|
|
55
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a results page or service proves who it is to Blendification.
|
|
3
|
+
*
|
|
4
|
+
* There is no API key. A page in a browser signs its viewer in with their
|
|
5
|
+
* Blendification account (OAuth 2.1 authorization code with PKCE, the same
|
|
6
|
+
* sign-in MCP clients use) and holds only that person's short-lived token. A
|
|
7
|
+
* server (a page's backend, a CRM sync, an agent) uses OAuth client credentials
|
|
8
|
+
* issued by Blendification for that purpose. Either way the token carries the
|
|
9
|
+
* scope it was granted, and a read-only token reaches only the read tools.
|
|
10
|
+
*
|
|
11
|
+
* Works in browsers and in Node 18+ (fetch, Web Crypto and URL are global in both).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_PAGE_SCOPE = 'openid mcp/read';
|
|
15
|
+
export const DEFAULT_SERVICE_SCOPE = 'mcp/read';
|
|
16
|
+
const EXPIRY_MARGIN_MS = 60_000;
|
|
17
|
+
/** OAuth errors that mean the refresh token will never work again. */
|
|
18
|
+
const DEAD_REFRESH_ERRORS = new Set(['invalid_grant', 'invalid_client', 'unauthorized_client']);
|
|
19
|
+
|
|
20
|
+
export class ResultsAuthError extends Error {
|
|
21
|
+
constructor(message, { code = 'not_signed_in', cause } = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'ResultsAuthError';
|
|
24
|
+
this.code = code;
|
|
25
|
+
if (cause) this.cause = cause;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function base64url(bytes) {
|
|
30
|
+
let s = '';
|
|
31
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
32
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function randomString(byteLength = 48) {
|
|
36
|
+
const bytes = new Uint8Array(byteLength);
|
|
37
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
38
|
+
return base64url(bytes);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function pkceChallenge(verifier) {
|
|
42
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
|
43
|
+
return base64url(new Uint8Array(digest));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A Storage-shaped map, for Node and for tests. */
|
|
47
|
+
export function memoryStorage() {
|
|
48
|
+
const m = new Map();
|
|
49
|
+
return {
|
|
50
|
+
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
|
51
|
+
setItem: (k, v) => { m.set(k, String(v)); },
|
|
52
|
+
removeItem: (k) => { m.delete(k); },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The OAuth server metadata a Blendification MCP endpoint advertises. */
|
|
57
|
+
export async function discoverAuthServer(mcpUrl, fetchFn) {
|
|
58
|
+
const origin = new URL(mcpUrl).origin;
|
|
59
|
+
const res = await fetchFn(`${origin}/.well-known/oauth-authorization-server`, { headers: { accept: 'application/json' } });
|
|
60
|
+
if (!res.ok) throw new ResultsAuthError(`Could not read the sign-in settings from ${origin} (HTTP ${res.status}).`, { code: 'discovery_failed' });
|
|
61
|
+
const meta = await res.json();
|
|
62
|
+
if (!meta?.token_endpoint) throw new ResultsAuthError(`${origin} did not advertise a token endpoint.`, { code: 'discovery_failed' });
|
|
63
|
+
return meta;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function postForm(fetchFn, url, form, headers = {}) {
|
|
67
|
+
const res = await fetchFn(url, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json', ...headers },
|
|
70
|
+
body: new URLSearchParams(form).toString(),
|
|
71
|
+
});
|
|
72
|
+
let json = null;
|
|
73
|
+
try { json = await res.json(); } catch { /* not JSON */ }
|
|
74
|
+
if (!res.ok || !json?.access_token) {
|
|
75
|
+
const reason = json?.error_description || json?.error || `HTTP ${res.status}`;
|
|
76
|
+
throw new ResultsAuthError(`The token request was refused: ${reason}.`, { code: json?.error || 'token_refused' });
|
|
77
|
+
}
|
|
78
|
+
return json;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toStored(tokens, previous = null, now = Date.now()) {
|
|
82
|
+
return {
|
|
83
|
+
access_token: tokens.access_token,
|
|
84
|
+
refresh_token: tokens.refresh_token || previous?.refresh_token || null,
|
|
85
|
+
id_token: tokens.id_token || previous?.id_token || null,
|
|
86
|
+
scope: tokens.scope || previous?.scope || null,
|
|
87
|
+
expires_at: now + (Number(tokens.expires_in) || 3600) * 1000,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The claims of a JWT, unverified. For display (who is signed in), never for trust. */
|
|
92
|
+
export function readJwtClaims(token) {
|
|
93
|
+
try {
|
|
94
|
+
const part = String(token).split('.')[1];
|
|
95
|
+
const binary = atob(part.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(part.length / 4) * 4, '='));
|
|
96
|
+
return JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))));
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Sign the page's viewer in with their Blendification account.
|
|
104
|
+
*
|
|
105
|
+
* Register the page once (registerPageClient, or POST /register on the MCP
|
|
106
|
+
* host) with its redirect URI, and pass the client id it returns. The client id
|
|
107
|
+
* is not a secret; it only names the page to the sign-in service.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} opts
|
|
110
|
+
* @param {string} opts.clientId
|
|
111
|
+
* @param {string} opts.redirectUri - exactly as registered
|
|
112
|
+
* @param {string} [opts.scope] - default "openid mcp/read" (read-only)
|
|
113
|
+
* @param {Storage} [opts.storage] - where tokens live (default localStorage, else memory)
|
|
114
|
+
* @param {Storage} [opts.sessionStorage] - where the in-flight sign-in lives (default sessionStorage)
|
|
115
|
+
* @param {string} [opts.keyPrefix]
|
|
116
|
+
* @param {(url: string) => void} [opts.navigate] - default location.assign
|
|
117
|
+
*/
|
|
118
|
+
export function pkceSignIn(opts = {}) {
|
|
119
|
+
const { clientId, redirectUri } = opts;
|
|
120
|
+
if (!clientId) throw new ResultsAuthError('pkceSignIn needs clientId.', { code: 'config' });
|
|
121
|
+
if (!redirectUri) throw new ResultsAuthError('pkceSignIn needs redirectUri.', { code: 'config' });
|
|
122
|
+
const scope = opts.scope || DEFAULT_PAGE_SCOPE;
|
|
123
|
+
const prefix = opts.keyPrefix || `blendification.results.${clientId}`;
|
|
124
|
+
const store = opts.storage || globalThis.localStorage || memoryStorage();
|
|
125
|
+
const pending = opts.sessionStorage || globalThis.sessionStorage || store;
|
|
126
|
+
const navigate = opts.navigate || ((url) => globalThis.location.assign(url));
|
|
127
|
+
const TOKENS = `${prefix}.tokens`;
|
|
128
|
+
const FLIGHT = `${prefix}.signin`;
|
|
129
|
+
// Set by createResultsClient; given here too so handleRedirect can run before the client exists.
|
|
130
|
+
let fetchFn = opts.fetch || globalThis.fetch?.bind(globalThis) || null;
|
|
131
|
+
let mcpUrl = opts.mcpUrl || null;
|
|
132
|
+
let meta = null;
|
|
133
|
+
let refreshing = null;
|
|
134
|
+
const listeners = new Set();
|
|
135
|
+
|
|
136
|
+
const metadata = async () => {
|
|
137
|
+
if (!meta) {
|
|
138
|
+
if (!opts.authServer && !mcpUrl) {
|
|
139
|
+
throw new ResultsAuthError('pkceSignIn does not know the MCP address yet: pass mcpUrl, or create the results client first.', { code: 'config' });
|
|
140
|
+
}
|
|
141
|
+
meta = opts.authServer || await discoverAuthServer(mcpUrl, fetchFn);
|
|
142
|
+
}
|
|
143
|
+
return meta;
|
|
144
|
+
};
|
|
145
|
+
const read = () => {
|
|
146
|
+
try { return JSON.parse(store.getItem(TOKENS) || 'null'); } catch { return null; }
|
|
147
|
+
};
|
|
148
|
+
const write = (value) => {
|
|
149
|
+
if (value) store.setItem(TOKENS, JSON.stringify(value)); else store.removeItem(TOKENS);
|
|
150
|
+
for (const fn of listeners) { try { fn(Boolean(value)); } catch { /* a listener's problem */ } }
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
kind: 'pkce',
|
|
155
|
+
scope,
|
|
156
|
+
attach(ctx) { fetchFn = ctx.fetch || fetchFn; mcpUrl = ctx.mcpUrl || mcpUrl; },
|
|
157
|
+
|
|
158
|
+
/** Send the viewer to the Blendification sign-in page. Resolves only if navigation is stubbed. */
|
|
159
|
+
async signIn({ returnTo } = {}) {
|
|
160
|
+
const m = await metadata();
|
|
161
|
+
if (!m.authorization_endpoint) throw new ResultsAuthError('The sign-in service advertised no authorization endpoint.', { code: 'discovery_failed' });
|
|
162
|
+
const verifier = randomString(48);
|
|
163
|
+
const state = randomString(16);
|
|
164
|
+
pending.setItem(FLIGHT, JSON.stringify({ verifier, state, returnTo: returnTo ?? globalThis.location?.href ?? null, at: Date.now() }));
|
|
165
|
+
const u = new URL(m.authorization_endpoint);
|
|
166
|
+
u.search = new URLSearchParams({
|
|
167
|
+
response_type: 'code',
|
|
168
|
+
client_id: clientId,
|
|
169
|
+
redirect_uri: redirectUri,
|
|
170
|
+
scope,
|
|
171
|
+
state,
|
|
172
|
+
code_challenge: await pkceChallenge(verifier),
|
|
173
|
+
code_challenge_method: 'S256',
|
|
174
|
+
}).toString();
|
|
175
|
+
navigate(u.toString());
|
|
176
|
+
return u.toString();
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Finish a sign-in when the page loads at its redirect URI. Call it on every
|
|
181
|
+
* load; it does nothing when the URL carries no sign-in answer.
|
|
182
|
+
* @returns {Promise<{ handled: boolean, signedIn: boolean, returnTo?: string|null }>}
|
|
183
|
+
*/
|
|
184
|
+
async handleRedirect({ url = globalThis.location?.href, cleanUrl = true } = {}) {
|
|
185
|
+
const untouched = { handled: false, signedIn: Boolean(read()) };
|
|
186
|
+
if (!url) return untouched;
|
|
187
|
+
const u = new URL(url);
|
|
188
|
+
const target = new URL(redirectUri);
|
|
189
|
+
const error = u.searchParams.get('error');
|
|
190
|
+
const code = u.searchParams.get('code');
|
|
191
|
+
const state = u.searchParams.get('state');
|
|
192
|
+
// Only the redirect page, and only while a sign-in this page started is
|
|
193
|
+
// pending: any other ?code= or ?error= belongs to someone else and is left alone.
|
|
194
|
+
if (u.origin !== target.origin || u.pathname !== target.pathname || (!code && !error)) return untouched;
|
|
195
|
+
let flight = null;
|
|
196
|
+
try { flight = JSON.parse(pending.getItem(FLIGHT) || 'null'); } catch { /* none */ }
|
|
197
|
+
if (!flight) return untouched;
|
|
198
|
+
pending.removeItem(FLIGHT);
|
|
199
|
+
if (cleanUrl && globalThis.history?.replaceState) {
|
|
200
|
+
['code', 'state', 'error', 'error_description'].forEach((k) => u.searchParams.delete(k));
|
|
201
|
+
globalThis.history.replaceState(globalThis.history.state, '', u.toString());
|
|
202
|
+
}
|
|
203
|
+
if (error) throw new ResultsAuthError(`Sign-in was not completed: ${u.searchParams.get('error_description') || error}.`, { code: error });
|
|
204
|
+
if (!flight || flight.state !== state) {
|
|
205
|
+
throw new ResultsAuthError('This sign-in answer does not match a sign-in this page started; start again.', { code: 'state_mismatch' });
|
|
206
|
+
}
|
|
207
|
+
const m = await metadata();
|
|
208
|
+
const tokens = await postForm(fetchFn, m.token_endpoint, {
|
|
209
|
+
grant_type: 'authorization_code',
|
|
210
|
+
client_id: clientId,
|
|
211
|
+
code,
|
|
212
|
+
redirect_uri: redirectUri,
|
|
213
|
+
code_verifier: flight.verifier,
|
|
214
|
+
});
|
|
215
|
+
write(toStored(tokens));
|
|
216
|
+
return { handled: true, signedIn: true, returnTo: flight.returnTo ?? null };
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
isSignedIn() {
|
|
220
|
+
const t = read();
|
|
221
|
+
return Boolean(t && (t.refresh_token || t.expires_at > Date.now()));
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
/** Who is signed in, from the id token (unverified, for display). */
|
|
225
|
+
viewer() {
|
|
226
|
+
const claims = readJwtClaims(read()?.id_token);
|
|
227
|
+
return claims ? { email: claims.email ?? null, name: claims.name ?? claims['cognito:username'] ?? null, sub: claims.sub ?? null } : null;
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
canRefresh() { return Boolean(read()?.refresh_token); },
|
|
231
|
+
|
|
232
|
+
async getAccessToken({ forceRefresh = false } = {}) {
|
|
233
|
+
const t = read();
|
|
234
|
+
if (!t) throw new ResultsAuthError('Not signed in. Call signIn() first.');
|
|
235
|
+
if (!forceRefresh && t.expires_at - EXPIRY_MARGIN_MS > Date.now()) return t.access_token;
|
|
236
|
+
if (!t.refresh_token) { write(null); throw new ResultsAuthError('The sign-in has expired. Sign in again.', { code: 'expired' }); }
|
|
237
|
+
if (!refreshing) {
|
|
238
|
+
refreshing = (async () => {
|
|
239
|
+
const m = await metadata();
|
|
240
|
+
try {
|
|
241
|
+
const tokens = await postForm(fetchFn, m.token_endpoint, { grant_type: 'refresh_token', client_id: clientId, refresh_token: t.refresh_token });
|
|
242
|
+
write(toStored(tokens, t));
|
|
243
|
+
} catch (err) {
|
|
244
|
+
// Only a refusal of the refresh token itself ends the sign-in. A
|
|
245
|
+
// network blip or the sign-in service's 5xx keeps it, to try again.
|
|
246
|
+
if (err instanceof ResultsAuthError && DEAD_REFRESH_ERRORS.has(err.code)) {
|
|
247
|
+
write(null);
|
|
248
|
+
throw new ResultsAuthError('The sign-in could not be renewed. Sign in again.', { code: 'expired', cause: err });
|
|
249
|
+
}
|
|
250
|
+
throw new ResultsAuthError('The sign-in could not be renewed just now (the network or the sign-in service failed); try again.', { code: 'refresh_failed', cause: err });
|
|
251
|
+
}
|
|
252
|
+
})().finally(() => { refreshing = null; });
|
|
253
|
+
}
|
|
254
|
+
await refreshing;
|
|
255
|
+
return read().access_token;
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
/** Forget this page's tokens. The Blendification session itself is not ended. */
|
|
259
|
+
signOut() { write(null); },
|
|
260
|
+
|
|
261
|
+
/** Called with true or false whenever the signed-in state changes. Returns an unsubscribe function. */
|
|
262
|
+
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* OAuth client credentials, for a server. Never use this in a browser: the
|
|
268
|
+
* secret would be in every visitor's hands. Blendification issues the client
|
|
269
|
+
* id and secret for one company and one scope.
|
|
270
|
+
*
|
|
271
|
+
* @param {{ clientId: string, clientSecret: string, scope?: string, tokenEndpoint?: string }} opts
|
|
272
|
+
*/
|
|
273
|
+
export function clientCredentials(opts = {}) {
|
|
274
|
+
const { clientId, clientSecret } = opts;
|
|
275
|
+
if (!clientId || !clientSecret) throw new ResultsAuthError('clientCredentials needs clientId and clientSecret.', { code: 'config' });
|
|
276
|
+
if (typeof globalThis.window !== 'undefined' && typeof globalThis.document !== 'undefined' && !opts.allowInBrowser) {
|
|
277
|
+
throw new ResultsAuthError('clientCredentials is for servers; a browser page must use pkceSignIn.', { code: 'config' });
|
|
278
|
+
}
|
|
279
|
+
const scope = opts.scope || DEFAULT_SERVICE_SCOPE;
|
|
280
|
+
let fetchFn = null;
|
|
281
|
+
let mcpUrl = null;
|
|
282
|
+
let cached = null;
|
|
283
|
+
let inflight = null;
|
|
284
|
+
return {
|
|
285
|
+
kind: 'client_credentials',
|
|
286
|
+
scope,
|
|
287
|
+
attach(ctx) { fetchFn = ctx.fetch; mcpUrl = ctx.mcpUrl; },
|
|
288
|
+
canRefresh() { return true; },
|
|
289
|
+
isSignedIn() { return true; },
|
|
290
|
+
async getAccessToken({ forceRefresh = false } = {}) {
|
|
291
|
+
if (!forceRefresh && cached && cached.expires_at - EXPIRY_MARGIN_MS > Date.now()) return cached.access_token;
|
|
292
|
+
if (!inflight) {
|
|
293
|
+
inflight = (async () => {
|
|
294
|
+
const endpoint = opts.tokenEndpoint || (await discoverAuthServer(mcpUrl, fetchFn)).token_endpoint;
|
|
295
|
+
const tokens = await postForm(fetchFn, endpoint, { grant_type: 'client_credentials', scope }, {
|
|
296
|
+
authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
|
|
297
|
+
});
|
|
298
|
+
cached = toStored(tokens);
|
|
299
|
+
})().finally(() => { inflight = null; });
|
|
300
|
+
}
|
|
301
|
+
await inflight;
|
|
302
|
+
return cached.access_token;
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** A token you already hold (a test, a script). It is never renewed. */
|
|
308
|
+
export function staticToken(token) {
|
|
309
|
+
if (!token) throw new ResultsAuthError('staticToken needs a token.', { code: 'config' });
|
|
310
|
+
return {
|
|
311
|
+
kind: 'static',
|
|
312
|
+
attach() {},
|
|
313
|
+
canRefresh() { return false; },
|
|
314
|
+
isSignedIn() { return true; },
|
|
315
|
+
async getAccessToken() { return token; },
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Register a page with the sign-in service once, and get its client id. Run it
|
|
321
|
+
* from a script or a terminal, not on every page load: registrations are
|
|
322
|
+
* rate-limited and each one is a permanent record.
|
|
323
|
+
*
|
|
324
|
+
* @param {{ mcpUrl: string, redirectUris: string[], clientName?: string, fetch?: Function }} opts
|
|
325
|
+
* @returns {Promise<{ clientId: string, redirectUris: string[], raw: object }>}
|
|
326
|
+
*/
|
|
327
|
+
export async function registerPageClient({ mcpUrl, redirectUris, clientName = 'Blendification results page', fetch: f = globalThis.fetch } = {}) {
|
|
328
|
+
if (!mcpUrl) throw new ResultsAuthError('registerPageClient needs mcpUrl.', { code: 'config' });
|
|
329
|
+
if (!Array.isArray(redirectUris) || !redirectUris.length) throw new ResultsAuthError('registerPageClient needs redirectUris.', { code: 'config' });
|
|
330
|
+
const meta = await discoverAuthServer(mcpUrl, f);
|
|
331
|
+
const endpoint = meta.registration_endpoint || `${new URL(mcpUrl).origin}/register`;
|
|
332
|
+
const res = await f(endpoint, {
|
|
333
|
+
method: 'POST',
|
|
334
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
335
|
+
body: JSON.stringify({ client_name: clientName, redirect_uris: redirectUris, grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none' }),
|
|
336
|
+
});
|
|
337
|
+
const json = await res.json().catch(() => null);
|
|
338
|
+
if (!res.ok || !json?.client_id) {
|
|
339
|
+
throw new ResultsAuthError(`Registration was refused: ${json?.error_description || json?.error || `HTTP ${res.status}`}.`, { code: json?.error || 'registration_refused' });
|
|
340
|
+
}
|
|
341
|
+
return { clientId: json.client_id, redirectUris: json.redirect_uris || redirectUris, raw: json };
|
|
342
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The results client: calls Blendification's MCP tools over JSON-RPC and wraps
|
|
3
|
+
* the ones a results page needs.
|
|
4
|
+
*
|
|
5
|
+
* It speaks to the same /mcp endpoint an AI agent does, so a page sees exactly
|
|
6
|
+
* what its owner's agent sees, through the same tenant checks, and a read-only
|
|
7
|
+
* token reaches only the read tools.
|
|
8
|
+
*/
|
|
9
|
+
import { ResultsAuthError } from './auth.js';
|
|
10
|
+
|
|
11
|
+
export const PROTOCOL_VERSION = '2025-06-18';
|
|
12
|
+
|
|
13
|
+
export class ResultsError extends Error {
|
|
14
|
+
constructor(message, { code = null, tool = null, status = null } = {}) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'ResultsError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.tool = tool;
|
|
19
|
+
this.status = status;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseRpcBody(text, contentType = '') {
|
|
24
|
+
if (/text\/event-stream/i.test(contentType)) {
|
|
25
|
+
// The last JSON-RPC message in the stream is the answer.
|
|
26
|
+
const messages = text.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).filter(Boolean);
|
|
27
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
28
|
+
try {
|
|
29
|
+
const m = JSON.parse(messages[i]);
|
|
30
|
+
if (m && (m.result !== undefined || m.error)) return m;
|
|
31
|
+
} catch { /* keep looking */ }
|
|
32
|
+
}
|
|
33
|
+
throw new ResultsError('The server answered with an event stream that held no result.');
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(text);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new ResultsError(`The server answered with something that is not JSON: ${text.slice(0, 120)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Add one page of template_invite_progress to a running total. */
|
|
43
|
+
export function mergeProgress(total, page) {
|
|
44
|
+
const keys = ['total', 'notStarted', 'started', 'complete', 'abandoned'];
|
|
45
|
+
const out = total ? { ...total } : { templateId: page.templateId ?? null, templateName: page.templateName ?? null, inviteGroupName: page.inviteGroupName ?? null, since: page.since ?? null };
|
|
46
|
+
for (const k of keys) out[k] = (out[k] || 0) + (Number(page[k]) || 0);
|
|
47
|
+
const minOf = (a, b) => (!a ? b ?? null : !b ? a : a < b ? a : b);
|
|
48
|
+
const maxOf = (a, b) => (!a ? b ?? null : !b ? a : a > b ? a : b);
|
|
49
|
+
out.firstConversationDate = minOf(out.firstConversationDate, page.firstConversationDate);
|
|
50
|
+
out.lastStartedAt = maxOf(out.lastStartedAt, page.lastStartedAt);
|
|
51
|
+
out.lastCompletedAt = maxOf(out.lastCompletedAt, page.lastCompletedAt);
|
|
52
|
+
for (const [list, key] of [['byInviteGroup', 'inviteGroup'], ['byTemplate', 'templateId']]) {
|
|
53
|
+
if (!Array.isArray(page[list])) continue;
|
|
54
|
+
const merged = new Map((out[list] || []).map((b) => [b[key] ?? '', { ...b }]));
|
|
55
|
+
for (const b of page[list]) {
|
|
56
|
+
const id = b[key] ?? '';
|
|
57
|
+
const cur = merged.get(id) || { [key]: b[key] ?? null, total: 0, notStarted: 0, started: 0, complete: 0, abandoned: 0 };
|
|
58
|
+
for (const k of keys) cur[k] = (cur[k] || 0) + (Number(b[k]) || 0);
|
|
59
|
+
merged.set(id, cur);
|
|
60
|
+
}
|
|
61
|
+
out[list] = [...merged.values()]
|
|
62
|
+
.map((b) => ({ ...b, completionRatePercent: b.total ? Math.round((b.complete / b.total) * 1000) / 10 : null }))
|
|
63
|
+
.sort((a, b) => b.total - a.total);
|
|
64
|
+
}
|
|
65
|
+
out.completionRatePercent = out.total ? Math.round((out.complete / out.total) * 1000) / 10 : null;
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {object} opts
|
|
73
|
+
* @param {string} opts.mcpUrl - the MCP endpoint, e.g. https://<host>/mcp
|
|
74
|
+
* @param {object} opts.auth - pkceSignIn(...), clientCredentials(...), staticToken(...), or { getAccessToken }
|
|
75
|
+
* @param {Function} [opts.fetch]
|
|
76
|
+
*/
|
|
77
|
+
export function createResultsClient(opts = {}) {
|
|
78
|
+
const { mcpUrl, auth } = opts;
|
|
79
|
+
if (!mcpUrl) throw new ResultsError('createResultsClient needs mcpUrl (the /mcp address your connection uses).');
|
|
80
|
+
try { new URL(mcpUrl); } catch { throw new ResultsError(`mcpUrl is not a URL: ${mcpUrl}`); }
|
|
81
|
+
if (!auth || typeof auth.getAccessToken !== 'function') throw new ResultsError('createResultsClient needs auth: pkceSignIn(), clientCredentials() or staticToken().');
|
|
82
|
+
const fetchFn = opts.fetch || globalThis.fetch?.bind(globalThis);
|
|
83
|
+
if (!fetchFn) throw new ResultsError('No fetch is available; pass opts.fetch.');
|
|
84
|
+
auth.attach?.({ mcpUrl, fetch: fetchFn });
|
|
85
|
+
let rpcId = 0;
|
|
86
|
+
|
|
87
|
+
async function rpc(method, params, retried = false) {
|
|
88
|
+
const token = await auth.getAccessToken();
|
|
89
|
+
const res = await fetchFn(mcpUrl, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
'content-type': 'application/json',
|
|
93
|
+
accept: 'application/json, text/event-stream',
|
|
94
|
+
'mcp-protocol-version': PROTOCOL_VERSION,
|
|
95
|
+
authorization: `Bearer ${token}`,
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method, params }),
|
|
98
|
+
});
|
|
99
|
+
if (res.status === 401) {
|
|
100
|
+
if (!retried && auth.canRefresh?.()) {
|
|
101
|
+
await auth.getAccessToken({ forceRefresh: true });
|
|
102
|
+
return rpc(method, params, true);
|
|
103
|
+
}
|
|
104
|
+
throw new ResultsAuthError('Blendification did not accept the credentials (401). Sign in again.', { code: 'unauthorized' });
|
|
105
|
+
}
|
|
106
|
+
const text = await res.text();
|
|
107
|
+
if (!res.ok) throw new ResultsError(`The MCP endpoint answered HTTP ${res.status}: ${text.slice(0, 200)}`, { status: res.status });
|
|
108
|
+
const message = parseRpcBody(text, res.headers?.get?.('content-type') || '');
|
|
109
|
+
if (message.error) throw new ResultsError(message.error.message || 'JSON-RPC error', { code: message.error.code });
|
|
110
|
+
return message.result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Call any MCP tool the token may use, and return its result. */
|
|
114
|
+
async function call(name, args = {}) {
|
|
115
|
+
const result = await rpc('tools/call', { name, arguments: args });
|
|
116
|
+
const text = Array.isArray(result?.content) ? result.content.find((c) => c?.type === 'text')?.text : null;
|
|
117
|
+
if (result?.isError) throw new ResultsError(text || `${name} failed.`, { tool: name });
|
|
118
|
+
if (result?.structuredContent !== undefined && result.structuredContent !== null) return result.structuredContent;
|
|
119
|
+
if (typeof text === 'string') {
|
|
120
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function listTools() {
|
|
126
|
+
const result = await rpc('tools/list', {});
|
|
127
|
+
return result?.tools ?? [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** template_invite_progress, read to the end and added up. */
|
|
131
|
+
async function progress({ templateId, inviteGroupName, since } = {}) {
|
|
132
|
+
let total = null;
|
|
133
|
+
let nextToken;
|
|
134
|
+
let pages = 0;
|
|
135
|
+
do {
|
|
136
|
+
const page = await call('template_invite_progress', { templateId, inviteGroupName, since, ...(nextToken ? { nextToken } : {}) });
|
|
137
|
+
total = mergeProgress(total, page);
|
|
138
|
+
nextToken = page?.nextToken || null;
|
|
139
|
+
pages += 1;
|
|
140
|
+
} while (nextToken && pages < 200);
|
|
141
|
+
return { ...total, complete: total.complete, pages, finished: !nextToken };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** One page of list_template_conversations. */
|
|
145
|
+
function conversationPage(args = {}) {
|
|
146
|
+
return call('list_template_conversations', args);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Every conversation of a template and/or invite group, page by page. */
|
|
150
|
+
async function* conversations({ templateId, inviteGroupName, status, since, limit } = {}) {
|
|
151
|
+
let nextToken;
|
|
152
|
+
let pages = 0;
|
|
153
|
+
do {
|
|
154
|
+
const page = await conversationPage({ templateId, inviteGroupName, status, since, limit, ...(nextToken ? { nextToken } : {}) });
|
|
155
|
+
for (const row of page?.conversations || page?.items || []) yield row;
|
|
156
|
+
nextToken = page?.nextToken || null;
|
|
157
|
+
pages += 1;
|
|
158
|
+
} while (nextToken && pages < 1000);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A conversation's messages, oldest first, every page. resultsGroupId is the group it was read through. */
|
|
162
|
+
async function transcript(conversationId, { resultsGroupId, limit = 100 } = {}) {
|
|
163
|
+
if (!resultsGroupId) throw new ResultsError('transcript needs resultsGroupId: messages are read through the results group that holds the conversation.');
|
|
164
|
+
const messages = [];
|
|
165
|
+
let nextToken;
|
|
166
|
+
let last = null;
|
|
167
|
+
let pages = 0;
|
|
168
|
+
do {
|
|
169
|
+
last = await call('get_conversation_messages', { resultsGroupId, conversationId, limit, ...(nextToken ? { nextToken } : {}) });
|
|
170
|
+
messages.push(...(last?.messages || []));
|
|
171
|
+
nextToken = last?.nextToken || null;
|
|
172
|
+
pages += 1;
|
|
173
|
+
} while (nextToken && pages < 100);
|
|
174
|
+
return { conversationId, messages, complete: !nextToken };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Wait for a background job (an export, an analysis) to finish. */
|
|
178
|
+
async function waitForJob(jobId, { intervalMs = 3000, timeoutMs = 10 * 60_000, onUpdate } = {}) {
|
|
179
|
+
const deadline = Date.now() + timeoutMs;
|
|
180
|
+
for (;;) {
|
|
181
|
+
const status = await call('fusion_job_status', { jobId });
|
|
182
|
+
onUpdate?.(status);
|
|
183
|
+
const state = String(status?.status || '').toLowerCase();
|
|
184
|
+
// done, or partial (finished with some parts missing; the status says which).
|
|
185
|
+
if (state === 'done' || state === 'partial') return status;
|
|
186
|
+
if (state === 'error' || state === 'failed' || state === 'not_found') {
|
|
187
|
+
throw new ResultsError(status?.error || `Job ${jobId} ended as ${state}.`, { code: state });
|
|
188
|
+
}
|
|
189
|
+
if (Date.now() + intervalMs > deadline) throw new ResultsError(`Job ${jobId} was still running after ${Math.round(timeoutMs / 1000)} s.`, { code: 'timeout' });
|
|
190
|
+
await sleep(intervalMs);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Export a results group and return its download links and first page. */
|
|
195
|
+
async function exportGroup(resultsGroupId, { format = 'csv', conversationIds, includeMessages, includeFeatures, waitMs } = {}) {
|
|
196
|
+
const started = await call('export_results_group', { resultsGroupId, format, conversationIds, includeMessages, includeFeatures });
|
|
197
|
+
const jobId = started?.jobId;
|
|
198
|
+
if (!jobId) throw new ResultsError('export_results_group returned no jobId.', { tool: 'export_results_group' });
|
|
199
|
+
await waitForJob(jobId, waitMs ? { timeoutMs: waitMs } : {});
|
|
200
|
+
return call('get_results_group_export', { jobId });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Poll progress and call onUpdate when it changes. A page uses this for a
|
|
205
|
+
* live counter; a server that must react at once should use webhooks.
|
|
206
|
+
* @returns {() => void} stop
|
|
207
|
+
*/
|
|
208
|
+
function watchProgress(args, { intervalMs = 30_000, onUpdate, onError } = {}) {
|
|
209
|
+
let stopped = false;
|
|
210
|
+
let lastKey = null;
|
|
211
|
+
const tick = async () => {
|
|
212
|
+
if (stopped) return;
|
|
213
|
+
try {
|
|
214
|
+
const p = await progress(args);
|
|
215
|
+
const key = JSON.stringify([p.total, p.notStarted, p.started, p.complete, p.abandoned]);
|
|
216
|
+
if (key !== lastKey) { lastKey = key; onUpdate?.(p); }
|
|
217
|
+
} catch (err) {
|
|
218
|
+
onError?.(err);
|
|
219
|
+
}
|
|
220
|
+
if (!stopped) setTimeout(tick, Math.max(5000, intervalMs));
|
|
221
|
+
};
|
|
222
|
+
tick();
|
|
223
|
+
return () => { stopped = true; };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
auth,
|
|
228
|
+
mcpUrl,
|
|
229
|
+
call,
|
|
230
|
+
listTools,
|
|
231
|
+
progress,
|
|
232
|
+
conversationPage,
|
|
233
|
+
conversations,
|
|
234
|
+
transcript,
|
|
235
|
+
waitForJob,
|
|
236
|
+
exportGroup,
|
|
237
|
+
watchProgress,
|
|
238
|
+
summary: (resultsGroupId) => call('get_results_group_summary', { resultsGroupId }),
|
|
239
|
+
resultsGroups: (args = {}) => call('list_results_groups', args),
|
|
240
|
+
templates: (args = {}) => call('list_survey_templates', args),
|
|
241
|
+
};
|
|
242
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @blendification/results: read Blendification results from your own page or
|
|
3
|
+
* server, and verify the webhooks it sends.
|
|
4
|
+
*
|
|
5
|
+
* import { createResultsClient, pkceSignIn } from '@blendification/results';
|
|
6
|
+
* const auth = pkceSignIn({ clientId: '<registered client id>', redirectUri: location.origin + '/results' });
|
|
7
|
+
* const results = createResultsClient({ mcpUrl: 'https://<host>/mcp', auth });
|
|
8
|
+
* await auth.handleRedirect();
|
|
9
|
+
* if (!auth.isSignedIn()) await auth.signIn();
|
|
10
|
+
* const progress = await results.progress({ templateId: '<template id>' });
|
|
11
|
+
*
|
|
12
|
+
* Webhook verification is in '@blendification/results/webhooks' (Node only).
|
|
13
|
+
*/
|
|
14
|
+
export { createResultsClient, mergeProgress, ResultsError, PROTOCOL_VERSION } from './client.js';
|
|
15
|
+
export {
|
|
16
|
+
pkceSignIn,
|
|
17
|
+
clientCredentials,
|
|
18
|
+
staticToken,
|
|
19
|
+
registerPageClient,
|
|
20
|
+
discoverAuthServer,
|
|
21
|
+
memoryStorage,
|
|
22
|
+
readJwtClaims,
|
|
23
|
+
ResultsAuthError,
|
|
24
|
+
DEFAULT_PAGE_SCOPE,
|
|
25
|
+
DEFAULT_SERVICE_SCOPE,
|
|
26
|
+
} from './auth.js';
|
package/src/webhooks.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check that a webhook delivery came from Blendification. Node only (it uses
|
|
3
|
+
* node:crypto), so it is its own entry point: @blendification/results/webhooks.
|
|
4
|
+
*
|
|
5
|
+
* Pass the RAW request body, exactly as received, before any JSON parsing:
|
|
6
|
+
* re-serialised JSON will not match the signature.
|
|
7
|
+
*
|
|
8
|
+
* import { verifyWebhookSignature } from '@blendification/results/webhooks';
|
|
9
|
+
* const ok = verifyWebhookSignature({
|
|
10
|
+
* secret: process.env.BLENDIFICATION_WEBHOOK_SECRET,
|
|
11
|
+
* header: req.headers['blendification-signature'],
|
|
12
|
+
* body: rawBody,
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* The same rule as the sender (packages/shared-data/src/webhooks.js); a test
|
|
16
|
+
* pins the two together.
|
|
17
|
+
*/
|
|
18
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
19
|
+
|
|
20
|
+
export const SIGNATURE_HEADER = 'blendification-signature';
|
|
21
|
+
|
|
22
|
+
export function signWebhookPayload(secret, body, timestamp = Math.floor(Date.now() / 1000)) {
|
|
23
|
+
const mac = createHmac('sha256', String(secret)).update(`${timestamp}.${body}`).digest('hex');
|
|
24
|
+
return `t=${timestamp},v1=${mac}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {{ secret: string, header: string, body: string|Buffer, toleranceSeconds?: number, now?: number }} input
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
export function verifyWebhookSignature({ secret, header, body, toleranceSeconds = 300, now = Date.now() }) {
|
|
32
|
+
const raw = Buffer.isBuffer(body) ? body.toString('utf8') : body;
|
|
33
|
+
if (!secret || typeof header !== 'string' || typeof raw !== 'string') return false;
|
|
34
|
+
const parts = Object.fromEntries(header.split(',').map((kv) => {
|
|
35
|
+
const i = kv.indexOf('=');
|
|
36
|
+
return i < 0 ? [kv.trim(), ''] : [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
|
|
37
|
+
}));
|
|
38
|
+
const t = Number(parts.t);
|
|
39
|
+
if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 || '')) return false;
|
|
40
|
+
if (Math.abs(Math.floor(now / 1000) - t) > toleranceSeconds) return false;
|
|
41
|
+
const expected = Buffer.from(signWebhookPayload(secret, raw, t).split('v1=')[1], 'hex');
|
|
42
|
+
const given = Buffer.from(parts.v1, 'hex');
|
|
43
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
44
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
export declare const PROTOCOL_VERSION: string;
|
|
2
|
+
export declare const DEFAULT_PAGE_SCOPE: string;
|
|
3
|
+
export declare const DEFAULT_SERVICE_SCOPE: string;
|
|
4
|
+
|
|
5
|
+
export declare class ResultsError extends Error {
|
|
6
|
+
code: string | number | null;
|
|
7
|
+
tool: string | null;
|
|
8
|
+
status: number | null;
|
|
9
|
+
}
|
|
10
|
+
export declare class ResultsAuthError extends Error {
|
|
11
|
+
code: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StorageLike {
|
|
15
|
+
getItem(key: string): string | null;
|
|
16
|
+
setItem(key: string, value: string): void;
|
|
17
|
+
removeItem(key: string): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AuthProvider {
|
|
21
|
+
kind: 'pkce' | 'client_credentials' | 'static' | string;
|
|
22
|
+
attach?(ctx: { mcpUrl: string; fetch: typeof fetch }): void;
|
|
23
|
+
getAccessToken(opts?: { forceRefresh?: boolean }): Promise<string>;
|
|
24
|
+
canRefresh?(): boolean;
|
|
25
|
+
isSignedIn?(): boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PkceSignIn extends AuthProvider {
|
|
29
|
+
kind: 'pkce';
|
|
30
|
+
scope: string;
|
|
31
|
+
signIn(opts?: { returnTo?: string }): Promise<string>;
|
|
32
|
+
handleRedirect(opts?: { url?: string; cleanUrl?: boolean }): Promise<{ handled: boolean; signedIn: boolean; returnTo?: string | null }>;
|
|
33
|
+
isSignedIn(): boolean;
|
|
34
|
+
viewer(): { email: string | null; name: string | null; sub: string | null } | null;
|
|
35
|
+
signOut(): void;
|
|
36
|
+
onChange(listener: (signedIn: boolean) => void): () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AuthServerMetadata {
|
|
40
|
+
authorization_endpoint?: string;
|
|
41
|
+
token_endpoint: string;
|
|
42
|
+
registration_endpoint?: string;
|
|
43
|
+
[key: string]: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export declare function pkceSignIn(opts: {
|
|
47
|
+
clientId: string;
|
|
48
|
+
redirectUri: string;
|
|
49
|
+
scope?: string;
|
|
50
|
+
storage?: StorageLike;
|
|
51
|
+
sessionStorage?: StorageLike;
|
|
52
|
+
keyPrefix?: string;
|
|
53
|
+
navigate?: (url: string) => void;
|
|
54
|
+
authServer?: AuthServerMetadata;
|
|
55
|
+
mcpUrl?: string;
|
|
56
|
+
fetch?: typeof fetch;
|
|
57
|
+
}): PkceSignIn;
|
|
58
|
+
|
|
59
|
+
export declare function clientCredentials(opts: {
|
|
60
|
+
clientId: string;
|
|
61
|
+
clientSecret: string;
|
|
62
|
+
scope?: string;
|
|
63
|
+
tokenEndpoint?: string;
|
|
64
|
+
allowInBrowser?: boolean;
|
|
65
|
+
}): AuthProvider;
|
|
66
|
+
|
|
67
|
+
export declare function staticToken(token: string): AuthProvider;
|
|
68
|
+
|
|
69
|
+
export declare function registerPageClient(opts: {
|
|
70
|
+
mcpUrl: string;
|
|
71
|
+
redirectUris: string[];
|
|
72
|
+
clientName?: string;
|
|
73
|
+
fetch?: typeof fetch;
|
|
74
|
+
}): Promise<{ clientId: string; redirectUris: string[]; raw: Record<string, unknown> }>;
|
|
75
|
+
|
|
76
|
+
export declare function discoverAuthServer(mcpUrl: string, fetchFn: typeof fetch): Promise<AuthServerMetadata>;
|
|
77
|
+
export declare function memoryStorage(): StorageLike;
|
|
78
|
+
export declare function readJwtClaims(token: string): Record<string, unknown> | null;
|
|
79
|
+
|
|
80
|
+
export interface ProgressCounts {
|
|
81
|
+
total: number;
|
|
82
|
+
notStarted: number;
|
|
83
|
+
started: number;
|
|
84
|
+
complete: number;
|
|
85
|
+
abandoned: number;
|
|
86
|
+
completionRatePercent: number | null;
|
|
87
|
+
firstConversationDate?: string | null;
|
|
88
|
+
lastStartedAt?: string | null;
|
|
89
|
+
lastCompletedAt?: string | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface Progress extends ProgressCounts {
|
|
93
|
+
templateId: string | null;
|
|
94
|
+
templateName: string | null;
|
|
95
|
+
inviteGroupName: string | null;
|
|
96
|
+
since: string | null;
|
|
97
|
+
byInviteGroup?: Array<ProgressCounts & { inviteGroup: string | null }>;
|
|
98
|
+
byTemplate?: Array<ProgressCounts & { templateId: string | null }>;
|
|
99
|
+
pages: number;
|
|
100
|
+
finished: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ScopeArgs {
|
|
104
|
+
templateId?: string;
|
|
105
|
+
inviteGroupName?: string;
|
|
106
|
+
since?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type ConversationStatus = 'not started' | 'started' | 'complete';
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One conversation, as conversations() yields it and conversationPage() lists
|
|
113
|
+
* it (the list_template_conversations tool). A drift test in
|
|
114
|
+
* services/analysis-engine pins these names to the rows the server builds.
|
|
115
|
+
*/
|
|
116
|
+
export interface TrackedConversation {
|
|
117
|
+
conversationId: string;
|
|
118
|
+
/** 'not started', 'started' or 'complete'. */
|
|
119
|
+
status: string | null;
|
|
120
|
+
/** Started, then left without completing. */
|
|
121
|
+
abandoned: boolean;
|
|
122
|
+
/** Null for an anonymous conversation. */
|
|
123
|
+
respondentName: string | null;
|
|
124
|
+
/** Lower-cased. Null for an anonymous conversation. */
|
|
125
|
+
respondentEmail: string | null;
|
|
126
|
+
inviteGroup: string | null;
|
|
127
|
+
templateId: string | null;
|
|
128
|
+
interviewDate: string | null;
|
|
129
|
+
createdAt: string | null;
|
|
130
|
+
startedAt: string | null;
|
|
131
|
+
completedAt: string | null;
|
|
132
|
+
/** The participant's link: the template's hosted page when it names one, else the app's page. */
|
|
133
|
+
link: string | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One page of list_template_conversations. */
|
|
137
|
+
export interface ConversationPage {
|
|
138
|
+
templateId: string | null;
|
|
139
|
+
templateName: string | null;
|
|
140
|
+
inviteGroupName: string | null;
|
|
141
|
+
status: ConversationStatus[] | null;
|
|
142
|
+
since: string | null;
|
|
143
|
+
returned: number;
|
|
144
|
+
conversations: TrackedConversation[];
|
|
145
|
+
nextToken: string | null;
|
|
146
|
+
message: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface ResultsClient {
|
|
150
|
+
auth: AuthProvider;
|
|
151
|
+
mcpUrl: string;
|
|
152
|
+
call<T = any>(tool: string, args?: Record<string, unknown>): Promise<T>;
|
|
153
|
+
listTools(): Promise<Array<{ name: string; description: string; inputSchema: object }>>;
|
|
154
|
+
progress(args: ScopeArgs): Promise<Progress>;
|
|
155
|
+
conversationPage(args: ScopeArgs & { status?: ConversationStatus | ConversationStatus[]; limit?: number; nextToken?: string }): Promise<ConversationPage>;
|
|
156
|
+
conversations(args: ScopeArgs & { status?: ConversationStatus | ConversationStatus[]; limit?: number }): AsyncGenerator<TrackedConversation>;
|
|
157
|
+
transcript(conversationId: string, opts: { resultsGroupId: string; limit?: number }): Promise<{ conversationId: string; messages: any[]; complete: boolean }>;
|
|
158
|
+
waitForJob(jobId: string, opts?: { intervalMs?: number; timeoutMs?: number; onUpdate?: (status: any) => void }): Promise<any>;
|
|
159
|
+
exportGroup(resultsGroupId: string, opts?: { format?: 'json' | 'csv'; conversationIds?: string[]; includeMessages?: boolean; includeFeatures?: boolean; waitMs?: number }): Promise<any>;
|
|
160
|
+
watchProgress(args: ScopeArgs, opts?: { intervalMs?: number; onUpdate?: (p: Progress) => void; onError?: (err: unknown) => void }): () => void;
|
|
161
|
+
summary(resultsGroupId: string): Promise<any>;
|
|
162
|
+
resultsGroups(args?: Record<string, unknown>): Promise<any>;
|
|
163
|
+
templates(args?: Record<string, unknown>): Promise<any>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export declare function createResultsClient(opts: { mcpUrl: string; auth: AuthProvider; fetch?: typeof fetch }): ResultsClient;
|
|
167
|
+
export declare function mergeProgress(total: Progress | null, page: Record<string, unknown>): Progress;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const SIGNATURE_HEADER: string;
|
|
2
|
+
export declare function signWebhookPayload(secret: string, body: string, timestamp?: number): string;
|
|
3
|
+
export declare function verifyWebhookSignature(input: {
|
|
4
|
+
secret: string;
|
|
5
|
+
header: string | undefined;
|
|
6
|
+
body: string | Buffer;
|
|
7
|
+
toleranceSeconds?: number;
|
|
8
|
+
now?: number;
|
|
9
|
+
}): boolean;
|
|
10
|
+
|
|
11
|
+
export interface ConversationWebhookEvent {
|
|
12
|
+
id: string;
|
|
13
|
+
type: 'conversation.started' | 'conversation.completed' | 'webhook.test';
|
|
14
|
+
createdAt: string;
|
|
15
|
+
data: {
|
|
16
|
+
conversationId?: string;
|
|
17
|
+
templateId?: string | null;
|
|
18
|
+
companyId?: string | null;
|
|
19
|
+
status?: string | null;
|
|
20
|
+
inviteGroupName?: string | null;
|
|
21
|
+
respondent?: { name: string | null; email: string | null } | null;
|
|
22
|
+
startedAt?: string | null;
|
|
23
|
+
completedAt?: string | null;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
};
|
|
26
|
+
}
|