@pingroom/sdk 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -41
- package/dist/client.d.ts +14 -348
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +15 -1300
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +32 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +47 -1
- package/dist/errors.js.map +1 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +7 -1
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +3 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/guards.d.ts +7 -0
- package/dist/internal/guards.d.ts.map +1 -1
- package/dist/internal/guards.js +1 -1
- package/dist/internal/guards.js.map +1 -1
- package/dist/internal/request.d.ts +41 -0
- package/dist/internal/request.d.ts.map +1 -0
- package/dist/internal/request.js +78 -0
- package/dist/internal/request.js.map +1 -0
- package/dist/internal/retry.d.ts +9 -2
- package/dist/internal/retry.d.ts.map +1 -1
- package/dist/internal/retry.js +24 -6
- package/dist/internal/retry.js.map +1 -1
- package/dist/internal/validate.d.ts +13 -0
- package/dist/internal/validate.d.ts.map +1 -0
- package/dist/internal/validate.js +36 -0
- package/dist/internal/validate.js.map +1 -0
- package/dist/liveStatus.d.ts +106 -11
- package/dist/liveStatus.d.ts.map +1 -1
- package/dist/liveStatus.js +20 -3
- package/dist/liveStatus.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +30 -8
- package/dist/mcp.js.map +1 -1
- package/dist/namespaces/actions.d.ts +50 -0
- package/dist/namespaces/actions.d.ts.map +1 -0
- package/dist/namespaces/actions.js +219 -0
- package/dist/namespaces/actions.js.map +1 -0
- package/dist/namespaces/approvals.d.ts +23 -0
- package/dist/namespaces/approvals.d.ts.map +1 -0
- package/dist/namespaces/approvals.js +78 -0
- package/dist/namespaces/approvals.js.map +1 -0
- package/dist/namespaces/attachments.d.ts +37 -0
- package/dist/namespaces/attachments.d.ts.map +1 -0
- package/dist/namespaces/attachments.js +91 -0
- package/dist/namespaces/attachments.js.map +1 -0
- package/dist/namespaces/auth.d.ts +58 -0
- package/dist/namespaces/auth.d.ts.map +1 -0
- package/dist/namespaces/auth.js +242 -0
- package/dist/namespaces/auth.js.map +1 -0
- package/dist/namespaces/directory.d.ts +10 -0
- package/dist/namespaces/directory.d.ts.map +1 -0
- package/dist/namespaces/directory.js +19 -0
- package/dist/namespaces/directory.js.map +1 -0
- package/dist/namespaces/handoffs.d.ts +47 -0
- package/dist/namespaces/handoffs.d.ts.map +1 -0
- package/dist/namespaces/handoffs.js +126 -0
- package/dist/namespaces/handoffs.js.map +1 -0
- package/dist/namespaces/inbox.d.ts +17 -0
- package/dist/namespaces/inbox.d.ts.map +1 -0
- package/dist/namespaces/inbox.js +192 -0
- package/dist/namespaces/inbox.js.map +1 -0
- package/dist/namespaces/live.d.ts +57 -0
- package/dist/namespaces/live.d.ts.map +1 -0
- package/dist/namespaces/live.js +116 -0
- package/dist/namespaces/live.js.map +1 -0
- package/dist/namespaces/notifications.d.ts +25 -0
- package/dist/namespaces/notifications.d.ts.map +1 -0
- package/dist/namespaces/notifications.js +128 -0
- package/dist/namespaces/notifications.js.map +1 -0
- package/dist/namespaces/profile.d.ts +19 -0
- package/dist/namespaces/profile.d.ts.map +1 -0
- package/dist/namespaces/profile.js +22 -0
- package/dist/namespaces/profile.js.map +1 -0
- package/dist/namespaces/questions.d.ts +41 -0
- package/dist/namespaces/questions.d.ts.map +1 -0
- package/dist/namespaces/questions.js +107 -0
- package/dist/namespaces/questions.js.map +1 -0
- package/dist/namespaces/rooms.d.ts +13 -0
- package/dist/namespaces/rooms.d.ts.map +1 -0
- package/dist/namespaces/rooms.js +41 -0
- package/dist/namespaces/rooms.js.map +1 -0
- package/dist/namespaces/webhooks.d.ts +11 -0
- package/dist/namespaces/webhooks.d.ts.map +1 -0
- package/dist/namespaces/webhooks.js +32 -0
- package/dist/namespaces/webhooks.js.map +1 -0
- package/dist/types/approvals.d.ts +62 -0
- package/dist/types/approvals.d.ts.map +1 -0
- package/dist/types/approvals.js +2 -0
- package/dist/types/approvals.js.map +1 -0
- package/dist/types/auth.d.ts +185 -0
- package/dist/types/auth.d.ts.map +1 -0
- package/dist/types/auth.js +2 -0
- package/dist/types/auth.js.map +1 -0
- package/dist/types/common.d.ts +43 -0
- package/dist/types/common.d.ts.map +1 -0
- package/dist/types/common.js +2 -0
- package/dist/types/common.js.map +1 -0
- package/dist/types/handoffs.d.ts +121 -0
- package/dist/types/handoffs.d.ts.map +1 -0
- package/dist/types/handoffs.js +2 -0
- package/dist/types/handoffs.js.map +1 -0
- package/dist/types/inbox.d.ts +51 -0
- package/dist/types/inbox.d.ts.map +1 -0
- package/dist/types/inbox.js +2 -0
- package/dist/types/inbox.js.map +1 -0
- package/dist/types/notifications.d.ts +376 -0
- package/dist/types/notifications.d.ts.map +1 -0
- package/dist/types/notifications.js +2 -0
- package/dist/types/notifications.js.map +1 -0
- package/dist/types/profile.d.ts +30 -0
- package/dist/types/profile.d.ts.map +1 -0
- package/dist/types/profile.js +3 -0
- package/dist/types/profile.js.map +1 -0
- package/dist/types/questions.d.ts +210 -0
- package/dist/types/questions.d.ts.map +1 -0
- package/dist/types/questions.js +2 -0
- package/dist/types/questions.js.map +1 -0
- package/dist/types/rooms.d.ts +163 -0
- package/dist/types/rooms.d.ts.map +1 -0
- package/dist/types/rooms.js +3 -0
- package/dist/types/rooms.js.map +1 -0
- package/dist/types.d.ts +9 -1199
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/webhooks.d.ts +14 -4
- package/dist/webhooks.d.ts.map +1 -1
- package/dist/webhooks.js +30 -5
- package/dist/webhooks.js.map +1 -1
- package/package.json +4 -2
- package/src/client.ts +115 -0
- package/src/errors.ts +291 -0
- package/src/http.ts +225 -0
- package/src/index.ts +97 -0
- package/src/internal/async.ts +54 -0
- package/src/internal/guards.ts +129 -0
- package/src/internal/request.ts +97 -0
- package/src/internal/retry.ts +157 -0
- package/src/internal/url.ts +78 -0
- package/src/internal/validate.ts +42 -0
- package/src/linkPing.ts +56 -0
- package/src/liveStatus.ts +337 -0
- package/src/locationPing.ts +101 -0
- package/src/mcp.ts +161 -0
- package/src/namespaces/actions.ts +234 -0
- package/src/namespaces/approvals.ts +104 -0
- package/src/namespaces/attachments.ts +106 -0
- package/src/namespaces/auth.ts +284 -0
- package/src/namespaces/directory.ts +21 -0
- package/src/namespaces/handoffs.ts +160 -0
- package/src/namespaces/inbox.ts +253 -0
- package/src/namespaces/live.ts +134 -0
- package/src/namespaces/notifications.ts +152 -0
- package/src/namespaces/profile.ts +24 -0
- package/src/namespaces/questions.ts +133 -0
- package/src/namespaces/rooms.ts +53 -0
- package/src/namespaces/webhooks.ts +38 -0
- package/src/types/approvals.ts +67 -0
- package/src/types/auth.ts +202 -0
- package/src/types/common.ts +74 -0
- package/src/types/handoffs.ts +148 -0
- package/src/types/inbox.ts +64 -0
- package/src/types/notifications.ts +393 -0
- package/src/types/profile.ts +37 -0
- package/src/types/questions.ts +226 -0
- package/src/types/rooms.ts +169 -0
- package/src/types.ts +17 -0
- package/src/version.ts +2 -0
- package/src/webhooks.ts +373 -0
- package/dist/liveActivity.d.ts +0 -101
- package/dist/liveActivity.d.ts.map +0 -1
- package/dist/liveActivity.js +0 -83
- package/dist/liveActivity.js.map +0 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// `pr.auth`: registration, claim, app pairing and the credential lifecycle.
|
|
2
|
+
|
|
3
|
+
import { PingRoomError } from '../errors.js';
|
|
4
|
+
import type { HttpClient } from '../http.js';
|
|
5
|
+
import { sleep, throwIfAborted } from '../internal/async.js';
|
|
6
|
+
import { requireNonEmpty } from '../internal/guards.js';
|
|
7
|
+
import type {
|
|
8
|
+
AgentConnectionInfo,
|
|
9
|
+
AgentDisconnectResult,
|
|
10
|
+
Credential,
|
|
11
|
+
ClaimCompleteParams,
|
|
12
|
+
ClaimStartParams,
|
|
13
|
+
RegisterParams,
|
|
14
|
+
PairingStart,
|
|
15
|
+
PairingStatus,
|
|
16
|
+
PairedCredential,
|
|
17
|
+
StartPairingParams,
|
|
18
|
+
WaitForPairingOptions,
|
|
19
|
+
} from '../types.js';
|
|
20
|
+
import { dropUndefined } from '../internal/request.js';
|
|
21
|
+
import { isRecord } from '../internal/validate.js';
|
|
22
|
+
|
|
23
|
+
/** Drop a malformed or unsafe server-provided URL without rejecting its response. */
|
|
24
|
+
function normalizePublicHttpUrl(candidate: unknown): string | undefined {
|
|
25
|
+
if (typeof candidate !== 'string' || candidate.trim() === '') return undefined;
|
|
26
|
+
const trimmed = candidate.trim();
|
|
27
|
+
// eslint-disable-next-line no-control-regex
|
|
28
|
+
if (/[\u0000-\u001F\u007F-\u009F]/.test(trimmed)) return undefined;
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(trimmed);
|
|
31
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return undefined;
|
|
32
|
+
if (url.username !== '' || url.password !== '') return undefined;
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return trimmed;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Drop malformed or unsafe server-provided links without rejecting a valid credential. */
|
|
40
|
+
function normalizePairingLinks(value: unknown): { latest_pings: string; install_app?: string } | undefined {
|
|
41
|
+
if (!isRecord(value)) return undefined;
|
|
42
|
+
const latestPings = normalizePublicHttpUrl(value['latest_pings']);
|
|
43
|
+
if (!latestPings) return undefined;
|
|
44
|
+
const installApp = normalizePublicHttpUrl(value['install_app']);
|
|
45
|
+
return {
|
|
46
|
+
latest_pings: latestPings,
|
|
47
|
+
...(installApp ? { install_app: installApp } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The credential a pairing swapped in, and the one to hand back when it ends
|
|
53
|
+
* without an approval. Held by `pair_token`, so a PairingStart that was only
|
|
54
|
+
* copied or re-serialised still finds it.
|
|
55
|
+
*/
|
|
56
|
+
interface PendingPairing {
|
|
57
|
+
pairToken: string;
|
|
58
|
+
pending: string;
|
|
59
|
+
previous: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Agent credential lifecycle (auth.md flow). */
|
|
63
|
+
export class AuthApi {
|
|
64
|
+
private pairing: PendingPairing | null = null;
|
|
65
|
+
|
|
66
|
+
constructor(private readonly http: HttpClient) {}
|
|
67
|
+
|
|
68
|
+
/** Register an agent. Anonymous or via an identity assertion (ID-JAG / verified email). Public. */
|
|
69
|
+
register(params: RegisterParams): Promise<Credential> {
|
|
70
|
+
requireNonEmpty(params.type, 'type');
|
|
71
|
+
return this.http.request('POST', '/api/agent/auth', { auth: false, body: dropUndefined({ ...params }) });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Begin claiming a pre-claim credential onto a human account; emails an OTP. */
|
|
75
|
+
claimStart(params: ClaimStartParams): Promise<{ message: string; expires_in: number }> {
|
|
76
|
+
requireNonEmpty(params.email, 'email');
|
|
77
|
+
return this.http.request('POST', '/api/agent/auth/claim/start', { body: { email: params.email } });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Complete the claim with the emailed OTP; returns an active credential + handle. */
|
|
81
|
+
claimComplete(params: ClaimCompleteParams): Promise<Credential> {
|
|
82
|
+
requireNonEmpty(params.email, 'email');
|
|
83
|
+
requireNonEmpty(params.otp, 'otp');
|
|
84
|
+
return this.http.request('POST', '/api/agent/auth/claim/complete', { body: dropUndefined({ ...params }) });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Register a pending agent and create a short-lived link that opens the
|
|
89
|
+
* PingRoom app. The server owns the full grant shown to the approver; this
|
|
90
|
+
* client sends no scope list. The pending credential stays private inside the
|
|
91
|
+
* client and is replaced automatically once waitForPairing() succeeds. If the
|
|
92
|
+
* client already held a credential, it is put back when the pairing expires
|
|
93
|
+
* or the wait is aborted, so a re-pairing that never completes leaves the
|
|
94
|
+
* client on the working credential rather than the dead pending one.
|
|
95
|
+
*/
|
|
96
|
+
async startPairing(params: StartPairingParams = {}): Promise<PairingStart> {
|
|
97
|
+
const previousToken = this.http.getToken();
|
|
98
|
+
const pending = await this.register({
|
|
99
|
+
type: 'anonymous',
|
|
100
|
+
...(params.agent_label ? { agent_label: params.agent_label } : {}),
|
|
101
|
+
});
|
|
102
|
+
this.http.setToken(pending.credential);
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const started = await this.http.request<PairingStart>('POST', '/api/agent/auth/pair/start', {
|
|
106
|
+
body: {},
|
|
107
|
+
});
|
|
108
|
+
// A second pairing started while the first is still pending falls back
|
|
109
|
+
// to the credential beneath it, not to the first pairing's pending one.
|
|
110
|
+
this.pairing = {
|
|
111
|
+
pairToken: started.pair_token,
|
|
112
|
+
pending: pending.credential,
|
|
113
|
+
previous: this.pairing !== null && this.pairing.pending === previousToken
|
|
114
|
+
? this.pairing.previous
|
|
115
|
+
: previousToken,
|
|
116
|
+
};
|
|
117
|
+
return started;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (this.http.getToken() === pending.credential) {
|
|
120
|
+
this.http.setToken(previousToken);
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Read the current state of an app pairing using the pending credential. */
|
|
127
|
+
pairingStatus(options: Pick<WaitForPairingOptions, 'signal'> = {}): Promise<PairingStatus> {
|
|
128
|
+
return this.http.request('GET', '/api/agent/auth/pair/status', { signal: options.signal });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Wait until the person approves in PingRoom, or the short-lived link
|
|
133
|
+
* expires. Transient network, 429, and 5xx failures are retried inside the
|
|
134
|
+
* server-provided lifetime. On success the client immediately starts using
|
|
135
|
+
* the active credential returned by the server. When the link expires or the
|
|
136
|
+
* wait is aborted, the credential the client held before startPairing() is
|
|
137
|
+
* restored; calling waitForPairing() again after an abort resumes the same
|
|
138
|
+
* pairing.
|
|
139
|
+
*/
|
|
140
|
+
async waitForPairing(
|
|
141
|
+
pairing: PairingStart,
|
|
142
|
+
options: WaitForPairingOptions = {},
|
|
143
|
+
): Promise<PairedCredential> {
|
|
144
|
+
const tracked = this.pairing !== null && this.pairing.pairToken === pairing.pair_token
|
|
145
|
+
? this.pairing
|
|
146
|
+
: null;
|
|
147
|
+
if (tracked !== null && this.http.getToken() === tracked.previous) {
|
|
148
|
+
// Resuming after an abort put the earlier credential back.
|
|
149
|
+
this.http.setToken(tracked.pending);
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const paired = await this.pollPairing(pairing, options);
|
|
153
|
+
if (tracked !== null && this.pairing === tracked) this.pairing = null;
|
|
154
|
+
return paired;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (tracked !== null && this.pairing === tracked) {
|
|
157
|
+
const expired = error instanceof PingRoomError && error.code === 'pairing_expired';
|
|
158
|
+
if (expired || options.signal?.aborted === true) {
|
|
159
|
+
if (this.http.getToken() === tracked.pending) this.http.setToken(tracked.previous);
|
|
160
|
+
}
|
|
161
|
+
// An expired pairing cannot be resumed; an aborted one can.
|
|
162
|
+
if (expired) this.pairing = null;
|
|
163
|
+
}
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private async pollPairing(
|
|
169
|
+
pairing: PairingStart,
|
|
170
|
+
options: WaitForPairingOptions,
|
|
171
|
+
): Promise<PairedCredential> {
|
|
172
|
+
const lifetimeMs = Math.max(1, Number(pairing.expires_in) || 900) * 1000;
|
|
173
|
+
const requestedInterval = options.pollIntervalMs ?? pairing.poll_interval_ms;
|
|
174
|
+
const intervalMs = Math.min(Math.max(Number(requestedInterval) || 1500, 1000), 10_000);
|
|
175
|
+
const startedAt = Date.now();
|
|
176
|
+
const deadline = startedAt + lifetimeMs;
|
|
177
|
+
let transientFailures = 0;
|
|
178
|
+
|
|
179
|
+
while (Date.now() < deadline) {
|
|
180
|
+
throwIfAborted(options.signal);
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const status = await this.pairingStatus({ signal: options.signal });
|
|
184
|
+
throwIfAborted(options.signal);
|
|
185
|
+
transientFailures = 0;
|
|
186
|
+
|
|
187
|
+
if (status.status === 'active') {
|
|
188
|
+
if (
|
|
189
|
+
typeof status.credential !== 'string'
|
|
190
|
+
|| status.credential.trim() === ''
|
|
191
|
+
|| status.credential_type !== 'active'
|
|
192
|
+
|| (status.expires_in !== null
|
|
193
|
+
&& (typeof status.expires_in !== 'number'
|
|
194
|
+
|| !Number.isFinite(status.expires_in)
|
|
195
|
+
|| status.expires_in < 0))
|
|
196
|
+
// A null/absent `room` is valid when the account has no eligible
|
|
197
|
+
// private delivery room (and on older servers). Only a room that is
|
|
198
|
+
// present but malformed is a broken response.
|
|
199
|
+
|| (status.room != null
|
|
200
|
+
&& (typeof status.room.invite_code !== 'string'
|
|
201
|
+
|| status.room.invite_code.trim() === ''))
|
|
202
|
+
) {
|
|
203
|
+
throw new PingRoomError('PingRoom returned an incomplete approved pairing.', {
|
|
204
|
+
code: 'pairing_invalid_response',
|
|
205
|
+
body: { status: status.status },
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
this.http.setToken(status.credential);
|
|
209
|
+
// Pairing is server-authoritative. Some servers still include their
|
|
210
|
+
// internal grant marker for protocol compatibility; it is neither a
|
|
211
|
+
// client input nor part of the paired credential surface.
|
|
212
|
+
const { status: _status, scopes: _serverScopes, links: rawLinks, ...credential } = status;
|
|
213
|
+
const links = normalizePairingLinks(rawLinks);
|
|
214
|
+
return links ? { ...credential, links } : credential;
|
|
215
|
+
}
|
|
216
|
+
if (status.status === 'expired') {
|
|
217
|
+
throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
|
|
218
|
+
code: 'pairing_expired',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (
|
|
223
|
+
error instanceof PingRoomError
|
|
224
|
+
&& [401, 403, 404].includes(error.status)
|
|
225
|
+
&& Date.now() - startedAt >= lifetimeMs / 2
|
|
226
|
+
) {
|
|
227
|
+
// The pending bearer can expire before the server returns an
|
|
228
|
+
// explicit expired pairing state. Keep early auth failures intact.
|
|
229
|
+
throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
|
|
230
|
+
code: 'pairing_expired',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (
|
|
234
|
+
error instanceof PingRoomError
|
|
235
|
+
&& (error.code === 'network_error'
|
|
236
|
+
|| error.code === 'timeout'
|
|
237
|
+
|| error.status === 429
|
|
238
|
+
|| error.status >= 500)
|
|
239
|
+
) {
|
|
240
|
+
transientFailures += 1;
|
|
241
|
+
const backoffMs = Math.min(intervalMs * 2 ** Math.max(0, transientFailures - 3), 30_000);
|
|
242
|
+
await sleep(Math.max(0, Math.min(backoffMs, deadline - Date.now())), options.signal);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await sleep(Math.max(0, Math.min(intervalMs, deadline - Date.now())), options.signal);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
throw new PingRoomError('The PingRoom pairing link expired before it was approved.', {
|
|
252
|
+
code: 'pairing_expired',
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Rotate to a fresh credential (same scopes/handle). */
|
|
257
|
+
refresh(): Promise<Credential> {
|
|
258
|
+
return this.http.request('POST', '/api/agent/auth/refresh', { body: {} });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Revoke the current credential. */
|
|
262
|
+
revoke(): Promise<void> {
|
|
263
|
+
return this.http.request('POST', '/api/agent/auth/revoke', { body: {} });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* What this credential is connected to: the robot, the owner's public id and
|
|
268
|
+
* name, the home room and room memberships, and the room grant. Check
|
|
269
|
+
* `owner.id` and `handle` against the account and robot you approved before
|
|
270
|
+
* sending anything.
|
|
271
|
+
*/
|
|
272
|
+
connection(): Promise<AgentConnectionInfo> {
|
|
273
|
+
return this.http.request('GET', '/api/agent/connection');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Disconnect this agent: revokes the calling credential and its refresh
|
|
278
|
+
* credentials. The client keeps the now-revoked token; clear it with
|
|
279
|
+
* `setToken(null)` or pair again.
|
|
280
|
+
*/
|
|
281
|
+
disconnect(): Promise<AgentDisconnectResult> {
|
|
282
|
+
return this.http.request('POST', '/api/agent/disconnect', { body: {} });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// `pr.directory`: the public agent directory.
|
|
2
|
+
|
|
3
|
+
import type { HttpClient } from '../http.js';
|
|
4
|
+
import type { DirectoryEntry, DirectoryListInput, DirectoryProfile, Paginated } from '../types.js';
|
|
5
|
+
import { enc, dropUndefined } from '../internal/request.js';
|
|
6
|
+
|
|
7
|
+
/** Public, unauthenticated agent directory. */
|
|
8
|
+
export class DirectoryApi {
|
|
9
|
+
constructor(private readonly http: HttpClient) {}
|
|
10
|
+
|
|
11
|
+
list(input: DirectoryListInput = {}): Promise<Paginated<DirectoryEntry>> {
|
|
12
|
+
return this.http.request('GET', '/api/agents/directory', {
|
|
13
|
+
auth: false,
|
|
14
|
+
query: dropUndefined({ ...input }),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get(handle: string): Promise<DirectoryProfile> {
|
|
19
|
+
return this.http.request('GET', `/api/agents/directory/${enc(handle)}`, { auth: false });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// `pr.handoffs`: the direct, private agent-to-human loop.
|
|
2
|
+
|
|
3
|
+
import { PingRoomError } from '../errors.js';
|
|
4
|
+
import type { HttpClient } from '../http.js';
|
|
5
|
+
import {
|
|
6
|
+
assertStructuredData,
|
|
7
|
+
normalizeIdempotencyKey,
|
|
8
|
+
requireNonEmpty,
|
|
9
|
+
} from '../internal/guards.js';
|
|
10
|
+
import { pollUntilSettled } from '../internal/retry.js';
|
|
11
|
+
import type {
|
|
12
|
+
AskInput,
|
|
13
|
+
Handoff,
|
|
14
|
+
HandoffOptionInput,
|
|
15
|
+
HandoffOptionSpec,
|
|
16
|
+
ListHandoffsInput,
|
|
17
|
+
RequestAckInput,
|
|
18
|
+
WaitHandoffInput,
|
|
19
|
+
WaitForOutcomeOptions,
|
|
20
|
+
} from '../types.js';
|
|
21
|
+
import {
|
|
22
|
+
MIN_HANDOFF_POLL_INTERVAL_MS,
|
|
23
|
+
enc,
|
|
24
|
+
assertOnRetry,
|
|
25
|
+
dropUndefined,
|
|
26
|
+
waitTimeoutMs,
|
|
27
|
+
} from '../internal/request.js';
|
|
28
|
+
import { isRecord, sameId, invalidWaitResponse } from '../internal/validate.js';
|
|
29
|
+
|
|
30
|
+
const HANDOFF_STATES: ReadonlySet<string> = new Set(['open', 'acked', 'pending', 'answered', 'expired', 'cancelled']);
|
|
31
|
+
|
|
32
|
+
function validateHandoffWait(value: unknown, handoffId: string): Handoff {
|
|
33
|
+
const state = isRecord(value) ? value['state'] : undefined;
|
|
34
|
+
if (!isRecord(value) || !sameId(value['id'], handoffId) || typeof state !== 'string' || !HANDOFF_STATES.has(state)) {
|
|
35
|
+
return invalidWaitResponse('handoff', handoffId, value, 'state');
|
|
36
|
+
}
|
|
37
|
+
return value as unknown as Handoff;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A bare string option is shorthand for `{ value, label: value }`. */
|
|
41
|
+
function normalizeHandoffOptions(options: HandoffOptionSpec[]): HandoffOptionInput[] {
|
|
42
|
+
return options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Terminal states never change again — the wait loop can stop as soon as one lands. */
|
|
46
|
+
function isHandoffTerminal(handoff: Handoff): boolean {
|
|
47
|
+
const s = handoff.state;
|
|
48
|
+
return s === 'acked' || s === 'answered' || s === 'expired' || s === 'cancelled';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The Handoff façade: the Agent → one-human loop. Always DIRECT (exactly one
|
|
53
|
+
* human) and PRIVATE (no other room member can read it). Backed by the ack and
|
|
54
|
+
* question primitives, so its {@link Handoff.state | state} space is their union
|
|
55
|
+
* — kept DISTINCT per {@link Handoff.kind | kind}. A negative answer to a
|
|
56
|
+
* question handoff (e.g. `hold`) is a SUCCESSFUL `answered` result, not an error.
|
|
57
|
+
*
|
|
58
|
+
* const task = await pr.handoffs.requestAck({ prompt, idempotencyKey });
|
|
59
|
+
* const result = await pr.handoffs.waitForResult(task.id);
|
|
60
|
+
*
|
|
61
|
+
* const q = await pr.handoffs.ask({ prompt, options: ['deploy', 'hold'], idempotencyKey });
|
|
62
|
+
*/
|
|
63
|
+
export class HandoffsApi {
|
|
64
|
+
constructor(private readonly http: HttpClient) {}
|
|
65
|
+
|
|
66
|
+
/** Ask a human to acknowledge (an `ack` handoff). Resolves on the first acknowledgement. */
|
|
67
|
+
requestAck(input: RequestAckInput): Promise<Handoff> {
|
|
68
|
+
requireNonEmpty(input.prompt, 'prompt');
|
|
69
|
+
assertStructuredData(input.data);
|
|
70
|
+
return this.create('ack', input);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Ask a human a multi-option question (a `question` handoff). Resolves on the
|
|
75
|
+
* first tapped answer. Bare-string options normalize to `{ value, label }`.
|
|
76
|
+
*/
|
|
77
|
+
ask(input: AskInput): Promise<Handoff> {
|
|
78
|
+
requireNonEmpty(input.prompt, 'prompt');
|
|
79
|
+
assertStructuredData(input.data);
|
|
80
|
+
if (!Array.isArray(input.options) || input.options.length < 2 || input.options.length > 4) {
|
|
81
|
+
throw new PingRoomError('`options` must contain between 2 and 4 entries for a question handoff.', {
|
|
82
|
+
code: 'invalid_request',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return this.create('question', input, normalizeHandoffOptions(input.options));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Fetch a handoff's current state. */
|
|
89
|
+
get(id: string): Promise<Handoff> {
|
|
90
|
+
requireNonEmpty(id, 'id');
|
|
91
|
+
return this.http.request('GET', `/api/agent/handoffs/${enc(id)}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Block until the handoff resolves (or expires/cancels), looping the bounded
|
|
96
|
+
* long-poll under the hood. `expired`/`cancelled` and a negative answer (e.g.
|
|
97
|
+
* `hold`) all surface as normal terminal results — nothing is thrown for them.
|
|
98
|
+
*
|
|
99
|
+
* Polls start at least 2.1 s apart, so a short `timeout` stays under the
|
|
100
|
+
* route's 30-per-minute limit. A response that is not this handoff in a
|
|
101
|
+
* known state rejects with `invalid_response`. Network failures, 429 and 5xx
|
|
102
|
+
* are retried with backoff (honouring `retryAfter`), and reported to
|
|
103
|
+
* `onRetry`, until `signal` aborts or the outage outlasts the handoff's
|
|
104
|
+
* `expires_at` by 5 minutes (15 minutes of failures while no `expires_at` has
|
|
105
|
+
* been read); that rejects with `unavailable`. See {@link WaitForOutcomeOptions}.
|
|
106
|
+
*/
|
|
107
|
+
async waitForResult(id: string, input: WaitHandoffInput & WaitForOutcomeOptions = {}): Promise<Handoff> {
|
|
108
|
+
requireNonEmpty(id, 'id');
|
|
109
|
+
assertOnRetry(input.onRetry);
|
|
110
|
+
return pollUntilSettled({
|
|
111
|
+
poll: () => this.wait(id, input),
|
|
112
|
+
validate: (value) => validateHandoffWait(value, id),
|
|
113
|
+
settled: isHandoffTerminal,
|
|
114
|
+
expiresAt: (handoff) => handoff.expires_at,
|
|
115
|
+
what: `handoff ${id}`,
|
|
116
|
+
minIntervalMs: MIN_HANDOFF_POLL_INTERVAL_MS,
|
|
117
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
118
|
+
...(input.onRetry ? { onRetry: input.onRetry } : {}),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** List the agent's open handoffs or bounded recent history. */
|
|
123
|
+
async list(input: ListHandoffsInput = {}): Promise<Handoff[]> {
|
|
124
|
+
const res = await this.http.request<{ handoffs: Handoff[] }>('GET', '/api/agent/handoffs', {
|
|
125
|
+
query: dropUndefined({ state: input.state }),
|
|
126
|
+
});
|
|
127
|
+
return res.handoffs ?? [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** One bounded long-poll; returns the (possibly still-open) handoff. */
|
|
131
|
+
private wait(id: string, input: WaitHandoffInput): Promise<Handoff> {
|
|
132
|
+
return this.http.request('GET', `/api/agent/handoffs/${enc(id)}/wait`, {
|
|
133
|
+
query: dropUndefined({ timeout: input.timeout }),
|
|
134
|
+
timeoutMs: waitTimeoutMs(input.timeout),
|
|
135
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private create(
|
|
140
|
+
kind: 'ack' | 'question',
|
|
141
|
+
input: RequestAckInput,
|
|
142
|
+
options?: HandoffOptionInput[],
|
|
143
|
+
): Promise<Handoff> {
|
|
144
|
+
const body = dropUndefined({
|
|
145
|
+
kind,
|
|
146
|
+
prompt: input.prompt,
|
|
147
|
+
audience: { type: 'direct', user_id: input.target ?? 'me' },
|
|
148
|
+
expires_in: input.expiresIn,
|
|
149
|
+
urgency: input.urgency,
|
|
150
|
+
options,
|
|
151
|
+
correlation_id: input.correlationId,
|
|
152
|
+
reply_to: input.replyTo,
|
|
153
|
+
data: input.data,
|
|
154
|
+
});
|
|
155
|
+
return this.http.request('POST', '/api/agent/handoffs', {
|
|
156
|
+
body,
|
|
157
|
+
...(input.idempotencyKey ? { headers: { 'Idempotency-Key': normalizeIdempotencyKey(input.idempotencyKey) } } : {}),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|