@synap-core/sdk 1.2.0 → 1.4.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 +154 -0
- package/dist/chunk-J7XHAAFJ.js +79 -0
- package/dist/chunk-J7XHAAFJ.js.map +1 -0
- package/dist/connect.browser.cjs +22 -0
- package/dist/connect.browser.cjs.map +1 -0
- package/dist/connect.browser.d.cts +2 -0
- package/dist/connect.browser.d.ts +2 -0
- package/dist/connect.browser.js +5 -0
- package/dist/connect.browser.js.map +1 -0
- package/dist/connect.cjs +1487 -0
- package/dist/connect.cjs.map +1 -0
- package/dist/connect.d.cts +647 -0
- package/dist/connect.d.ts +647 -0
- package/dist/connect.js +1365 -0
- package/dist/connect.js.map +1 -0
- package/dist/testing.cjs +431 -19
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +160 -1
- package/dist/testing.d.ts +160 -1
- package/dist/testing.js +404 -19
- package/dist/testing.js.map +1 -1
- package/dist/types-C2KGNXEn.d.cts +95 -0
- package/dist/types-C2KGNXEn.d.ts +95 -0
- package/package.json +18 -10
- package/src/connect/auth.ts +535 -0
- package/src/connect/browser.ts +10 -0
- package/src/connect/client.ts +299 -0
- package/src/connect/cloud.ts +206 -0
- package/src/connect/discovery.ts +263 -0
- package/src/connect/encoding.ts +93 -0
- package/src/connect/errors.ts +115 -0
- package/src/connect/flow.ts +489 -0
- package/src/connect/guard.ts +25 -0
- package/src/connect/index.ts +87 -0
- package/src/connect/seal.ts +119 -0
- package/src/connect/store.ts +189 -0
- package/src/connect/types.ts +100 -0
- package/src/testing/auth-server.ts +512 -0
- package/src/testing/cloud.ts +84 -0
- package/src/testing/index.ts +7 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A server-side Hub REST client for one PodConnection: `<pod>/api/hub/*` with
|
|
3
|
+
* the connection's Bearer token. Results, not throws: `{ ok: true, … }` or
|
|
4
|
+
* `{ ok: false, error }` with a typed `code`.
|
|
5
|
+
*
|
|
6
|
+
* - `expired`: the access token is past its expiry here; nothing was sent
|
|
7
|
+
* (refresh in `proxy.ts` / a route handler, or connect again);
|
|
8
|
+
* - `unauthorized`: the pod refused the token (revoked, or expired there);
|
|
9
|
+
* - `forbidden`: not granted; `door_unsupported`: a door a space-scoped app
|
|
10
|
+
* token can never use (403 GRANT_DOOR_UNSUPPORTED, spec 17 §5);
|
|
11
|
+
* - `rate_limited` with `retryAfterSec`; `pod_unreachable` (network, 502-504).
|
|
12
|
+
*
|
|
13
|
+
* Writes are governed: `proposed` is SUCCESS (queued for the person's review,
|
|
14
|
+
* never retry it), with `reviewUrl` on the pod.
|
|
15
|
+
*
|
|
16
|
+
* The token goes to the pod's own origin only: paths are pod-relative, no
|
|
17
|
+
* redirect is followed.
|
|
18
|
+
*/
|
|
19
|
+
import { retryAfterSeconds } from "./errors";
|
|
20
|
+
import type { PodConnection, PodGrant, PodSpace } from "./types";
|
|
21
|
+
|
|
22
|
+
export type PodErrorCode =
|
|
23
|
+
| "expired"
|
|
24
|
+
| "unauthorized"
|
|
25
|
+
| "forbidden"
|
|
26
|
+
| "door_unsupported"
|
|
27
|
+
| "not_found"
|
|
28
|
+
| "invalid"
|
|
29
|
+
| "conflict"
|
|
30
|
+
| "rate_limited"
|
|
31
|
+
| "pod_unreachable"
|
|
32
|
+
| "server";
|
|
33
|
+
|
|
34
|
+
export interface PodError {
|
|
35
|
+
code: PodErrorCode;
|
|
36
|
+
/** The HTTP status, or null when nothing was answered. */
|
|
37
|
+
status: number | null;
|
|
38
|
+
retryAfterSec?: number;
|
|
39
|
+
/** The pod's own error text, for logs only. */
|
|
40
|
+
detail?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type PodResult<T> = { ok: true; data: T } | { ok: false; error: PodError };
|
|
44
|
+
|
|
45
|
+
export type PodWriteResult<T> =
|
|
46
|
+
| { ok: true; status: "applied"; data: T }
|
|
47
|
+
| { ok: true; status: "proposed"; proposalId: string | null; reviewUrl: string; data: T }
|
|
48
|
+
| { ok: false; error: PodError };
|
|
49
|
+
|
|
50
|
+
export type QueryValue = string | number | boolean | null | undefined | ReadonlyArray<string | number | boolean>;
|
|
51
|
+
|
|
52
|
+
/** One pending proposal, the pod's summary row (`GET /proposals?view=basic`). */
|
|
53
|
+
export interface PendingProposal {
|
|
54
|
+
id: string;
|
|
55
|
+
proposalType: string;
|
|
56
|
+
targetType: string;
|
|
57
|
+
targetId: string | null;
|
|
58
|
+
workspaceId: string | null;
|
|
59
|
+
/** ISO 8601, as the pod sent it. */
|
|
60
|
+
createdAt: string;
|
|
61
|
+
/** The agent that filed it (this app's agent, or another one), when known. */
|
|
62
|
+
agentUserId: string | null;
|
|
63
|
+
sessionId: string | null;
|
|
64
|
+
summary?: string;
|
|
65
|
+
/** Where the person reviews it: the pod's deep link. */
|
|
66
|
+
reviewUrl: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PendingProposals {
|
|
70
|
+
/** Every pending proposal the connection can see, not only this page (the pod COUNTs it). */
|
|
71
|
+
total: number;
|
|
72
|
+
proposals: PendingProposal[];
|
|
73
|
+
hasMore: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ListPendingProposalsOptions {
|
|
77
|
+
/** One granted space only. */
|
|
78
|
+
workspaceId?: string;
|
|
79
|
+
/** 1-200, default 20. */
|
|
80
|
+
limit?: number;
|
|
81
|
+
offset?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface PodClientOptions {
|
|
85
|
+
fetch?: typeof fetch;
|
|
86
|
+
now?: () => number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface PodClient {
|
|
90
|
+
podUrl: string;
|
|
91
|
+
user: PodConnection["user"];
|
|
92
|
+
grant: PodGrant[];
|
|
93
|
+
/** Whether the connection was granted `permission` (in `workspaceId` when given). */
|
|
94
|
+
can(permission: string, workspaceId?: string): boolean;
|
|
95
|
+
/** The granted spaces, each with its permissions (`spaces(connection)`). */
|
|
96
|
+
spaces(): PodSpace[];
|
|
97
|
+
/**
|
|
98
|
+
* "Waiting for review: N". The person's pending proposals in the spaces this
|
|
99
|
+
* connection may read proposals in (any agent's, not only this app's), from
|
|
100
|
+
* the pod's seamed `GET /proposals` door. Needs the `proposal.read`
|
|
101
|
+
* permission (ask for it in `requests`): without it the pod would answer an
|
|
102
|
+
* empty list, so this answers `forbidden` instead, without a request.
|
|
103
|
+
*/
|
|
104
|
+
listPendingProposals(opts?: ListPendingProposalsOptions): Promise<PodResult<PendingProposals>>;
|
|
105
|
+
get<T = unknown>(path: string, query?: Record<string, QueryValue>): Promise<PodResult<T>>;
|
|
106
|
+
post<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
|
|
107
|
+
patch<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
|
|
108
|
+
put<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
|
|
109
|
+
delete<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether `connection` was granted `permission` (in `workspaceId` when given). Exact match: grants carry no wildcard. */
|
|
113
|
+
export function can(connection: Pick<PodConnection, "grant">, permission: string, workspaceId?: string): boolean {
|
|
114
|
+
return connection.grant.some((g) => g.permission === permission && (workspaceId === undefined || g.workspaceId === workspaceId));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The grant by space, in the grant's order, each permission once. The pod
|
|
119
|
+
* sends one row per permission per space; a space switcher wants one per space.
|
|
120
|
+
*/
|
|
121
|
+
export function spaces(connection: Pick<PodConnection, "grant">): PodSpace[] {
|
|
122
|
+
const bySpace = new Map<string, PodSpace>();
|
|
123
|
+
for (const g of connection.grant) {
|
|
124
|
+
const space = bySpace.get(g.workspaceId) ?? { workspaceId: g.workspaceId, workspaceName: g.workspaceName, permissions: [] };
|
|
125
|
+
if (!space.workspaceName && g.workspaceName) space.workspaceName = g.workspaceName;
|
|
126
|
+
if (!space.permissions.includes(g.permission)) space.permissions.push(g.permission);
|
|
127
|
+
bySpace.set(g.workspaceId, space);
|
|
128
|
+
}
|
|
129
|
+
return [...bySpace.values()];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const SAFE_PATH = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/;
|
|
133
|
+
|
|
134
|
+
function hubUrl(podUrl: string, path: string, query?: Record<string, QueryValue>): URL {
|
|
135
|
+
if (!SAFE_PATH.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\")) {
|
|
136
|
+
throw new TypeError(`Not a Hub API path: ${path}`);
|
|
137
|
+
}
|
|
138
|
+
const url = new URL(`${podUrl}/api/hub${path}`);
|
|
139
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
140
|
+
if (value === undefined || value === null) continue;
|
|
141
|
+
for (const v of Array.isArray(value) ? value : [value]) url.searchParams.append(key, String(v));
|
|
142
|
+
}
|
|
143
|
+
return url;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function errorFor(status: number, body: unknown, retryAfter: string | null, now: number): PodError {
|
|
147
|
+
const obj = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
|
148
|
+
const detail = typeof obj.error === "string" ? obj.error : typeof obj.message === "string" ? obj.message : undefined;
|
|
149
|
+
const base = { status, ...(detail !== undefined ? { detail } : {}) };
|
|
150
|
+
if (status === 401) return { code: "unauthorized", ...base };
|
|
151
|
+
if (status === 403) return { code: obj.code === "GRANT_DOOR_UNSUPPORTED" ? "door_unsupported" : "forbidden", ...base };
|
|
152
|
+
if (status === 404) return { code: "not_found", ...base };
|
|
153
|
+
if (status === 409) return { code: "conflict", ...base };
|
|
154
|
+
if (status === 400 || status === 422) return { code: "invalid", ...base };
|
|
155
|
+
if (status === 429) {
|
|
156
|
+
const retryAfterSec = retryAfterSeconds(retryAfter, now);
|
|
157
|
+
return { code: "rate_limited", ...base, ...(retryAfterSec !== undefined ? { retryAfterSec } : {}) };
|
|
158
|
+
}
|
|
159
|
+
if (status === 502 || status === 503 || status === 504) return { code: "pod_unreachable", ...base };
|
|
160
|
+
return { code: "server", ...base };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Where to review a proposal: the pod's answer, resolved against the pod and
|
|
165
|
+
* kept only on the pod's own origin; else the pod's deep link `/open/<id>`.
|
|
166
|
+
*/
|
|
167
|
+
export function reviewUrlFor(podUrl: string, reviewUrl: unknown, proposalId: string | null): string {
|
|
168
|
+
if (typeof reviewUrl === "string" && reviewUrl) {
|
|
169
|
+
try {
|
|
170
|
+
const url = new URL(reviewUrl, `${podUrl}/`);
|
|
171
|
+
if (url.origin === new URL(podUrl).origin) return url.href;
|
|
172
|
+
} catch {
|
|
173
|
+
// fall through to the deep link
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return proposalId ? `${podUrl}/open/${encodeURIComponent(proposalId)}` : podUrl;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function createPodClient(connection: PodConnection, opts: PodClientOptions = {}): PodClient {
|
|
180
|
+
const now = opts.now ?? Date.now;
|
|
181
|
+
const doFetch = () => opts.fetch ?? fetch;
|
|
182
|
+
|
|
183
|
+
async function request(method: string, path: string, body: unknown, query?: Record<string, QueryValue>): Promise<PodResult<unknown>> {
|
|
184
|
+
const url = hubUrl(connection.podUrl, path, query);
|
|
185
|
+
if (connection.accessExpiresAt <= now()) return { ok: false, error: { code: "expired", status: null } };
|
|
186
|
+
const headers: Record<string, string> = { Accept: "application/json", Authorization: `Bearer ${connection.accessToken}` };
|
|
187
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
188
|
+
let res: Response;
|
|
189
|
+
try {
|
|
190
|
+
res = await doFetch()(url.href, {
|
|
191
|
+
method,
|
|
192
|
+
headers,
|
|
193
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
194
|
+
redirect: "manual",
|
|
195
|
+
});
|
|
196
|
+
} catch {
|
|
197
|
+
return { ok: false, error: { code: "pod_unreachable", status: null } };
|
|
198
|
+
}
|
|
199
|
+
let data: unknown = null;
|
|
200
|
+
const text = await res.text().catch(() => "");
|
|
201
|
+
if (text) {
|
|
202
|
+
try {
|
|
203
|
+
data = JSON.parse(text);
|
|
204
|
+
} catch {
|
|
205
|
+
data = null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (res.status >= 200 && res.status < 300) return { ok: true, data };
|
|
209
|
+
return { ok: false, error: errorFor(res.status, data, res.headers.get("retry-after"), now()) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function write<T>(method: string, path: string, body: unknown): Promise<PodWriteResult<T>> {
|
|
213
|
+
const result = await request(method, path, body);
|
|
214
|
+
if (!result.ok) return result;
|
|
215
|
+
const obj = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
|
|
216
|
+
const proposalId = typeof obj.proposalId === "string" && obj.proposalId ? obj.proposalId : null;
|
|
217
|
+
if (obj.status === "proposed" || (obj.status === "pending" && proposalId)) {
|
|
218
|
+
return {
|
|
219
|
+
ok: true,
|
|
220
|
+
status: "proposed",
|
|
221
|
+
proposalId,
|
|
222
|
+
reviewUrl: reviewUrlFor(connection.podUrl, obj.reviewUrl, proposalId),
|
|
223
|
+
data: result.data as T,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return { ok: true, status: "applied", data: result.data as T };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function listPendingProposals(opts: ListPendingProposalsOptions = {}): Promise<PodResult<PendingProposals>> {
|
|
230
|
+
if (!can(connection, "proposal.read", opts.workspaceId)) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
error: { code: "forbidden", status: null, detail: "The connection was not granted proposal.read in that space" },
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const limit = Math.min(Math.max(Math.floor(opts.limit ?? 20), 1), 200);
|
|
237
|
+
const offset = Math.max(Math.floor(opts.offset ?? 0), 0);
|
|
238
|
+
const result = await request("GET", "/proposals", undefined, {
|
|
239
|
+
status: "pending",
|
|
240
|
+
view: "basic",
|
|
241
|
+
limit,
|
|
242
|
+
offset,
|
|
243
|
+
workspaceId: opts.workspaceId,
|
|
244
|
+
});
|
|
245
|
+
if (!result.ok) return result;
|
|
246
|
+
const obj = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
|
|
247
|
+
if (!Array.isArray(obj.proposals) || typeof obj.total !== "number") {
|
|
248
|
+
return { ok: false, error: { code: "server", status: 200, detail: "The pod's /proposals answer has no proposals or total" } };
|
|
249
|
+
}
|
|
250
|
+
const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
|
|
251
|
+
const proposals: PendingProposal[] = [];
|
|
252
|
+
for (const row of obj.proposals as unknown[]) {
|
|
253
|
+
if (!row || typeof row !== "object") continue;
|
|
254
|
+
const r = row as Record<string, unknown>;
|
|
255
|
+
const id = str(r.id);
|
|
256
|
+
if (!id) continue;
|
|
257
|
+
proposals.push({
|
|
258
|
+
id,
|
|
259
|
+
proposalType: str(r.proposalType) ?? "",
|
|
260
|
+
targetType: str(r.targetType) ?? "",
|
|
261
|
+
targetId: str(r.targetId),
|
|
262
|
+
workspaceId: str(r.workspaceId),
|
|
263
|
+
createdAt: str(r.createdAt) ?? "",
|
|
264
|
+
agentUserId: str(r.agentUserId),
|
|
265
|
+
sessionId: str(r.sessionId),
|
|
266
|
+
...(str(r.summary) ? { summary: str(r.summary)! } : {}),
|
|
267
|
+
reviewUrl: reviewUrlFor(connection.podUrl, null, id),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
ok: true,
|
|
272
|
+
data: { total: obj.total, proposals, hasMore: obj.hasMore === true || offset + proposals.length < obj.total },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
podUrl: connection.podUrl,
|
|
278
|
+
user: connection.user,
|
|
279
|
+
grant: connection.grant,
|
|
280
|
+
can: (permission, workspaceId) => can(connection, permission, workspaceId),
|
|
281
|
+
spaces: () => spaces(connection),
|
|
282
|
+
listPendingProposals,
|
|
283
|
+
get<T>(path: string, query?: Record<string, QueryValue>) {
|
|
284
|
+
return request("GET", path, undefined, query) as Promise<PodResult<T>>;
|
|
285
|
+
},
|
|
286
|
+
post<T>(path: string, body?: unknown) {
|
|
287
|
+
return write<T>("POST", path, body);
|
|
288
|
+
},
|
|
289
|
+
patch<T>(path: string, body?: unknown) {
|
|
290
|
+
return write<T>("PATCH", path, body);
|
|
291
|
+
},
|
|
292
|
+
put<T>(path: string, body?: unknown) {
|
|
293
|
+
return write<T>("PUT", path, body);
|
|
294
|
+
},
|
|
295
|
+
delete<T>(path: string, body?: unknown) {
|
|
296
|
+
return write<T>("DELETE", path, body);
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connecting through Synap Cloud (wave C1, doc 18 §1.1): the person picks
|
|
3
|
+
* their pod on the Control Plane instead of typing its address.
|
|
4
|
+
*
|
|
5
|
+
* app POST <cloud handler> → 303 <cp>/connect/pod?client_id=<CIMD url>&state=<nonce>
|
|
6
|
+
* CP the person signs in, picks a pod → 302 <initiate_login_uri>?iss=<pod issuer>&state=<nonce>
|
|
7
|
+
* app GET <cloud handler>?iss&state → state checked against this browser's sealed
|
|
8
|
+
* cookie → the SAME sign-in as a typed address
|
|
9
|
+
* (discovery, fresh PKCE + state, `iss` on the answer)
|
|
10
|
+
*
|
|
11
|
+
* The CP never sees a code or a token, and takes the return URL from the
|
|
12
|
+
* app's client metadata, never from the query. `state` is what makes the
|
|
13
|
+
* return trustworthy: it is drawn here, kept sealed in an HttpOnly cookie of
|
|
14
|
+
* this browser, and compared in constant time, so an `iss` link that another
|
|
15
|
+
* site sends a visitor to (login CSRF into an attacker's pod) is refused. A
|
|
16
|
+
* return without a matching `state` never starts a sign-in.
|
|
17
|
+
*
|
|
18
|
+
* The CP's answer only names a pod. The pod itself is then validated exactly
|
|
19
|
+
* like a typed address (`discoverPod`), and its answer like any other.
|
|
20
|
+
*/
|
|
21
|
+
import { canonicalPodUrl } from "./discovery";
|
|
22
|
+
import { constantTimeEqual, randomToken } from "./encoding";
|
|
23
|
+
import { ConnectError, fromOAuthError } from "./errors";
|
|
24
|
+
import { seal, unseal, type AppSecret } from "./seal";
|
|
25
|
+
|
|
26
|
+
/** How long a Synap Cloud sign-in may take, from the click to the pod chooser's return. */
|
|
27
|
+
export const CLOUD_TRANSACTION_TTL_MS = 10 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
/** The state the CP accepts (`^[A-Za-z0-9._~-]{16,256}$`); ours is 43 base64url characters. */
|
|
30
|
+
const STATE_RE = /^[A-Za-z0-9._~-]{16,256}$/;
|
|
31
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
32
|
+
|
|
33
|
+
/** A sealed `CloudTransaction`: opaque, safe to put in an HttpOnly cookie. */
|
|
34
|
+
export type SealedCloudTransaction = string & { readonly __sealedCloudTransaction: true };
|
|
35
|
+
|
|
36
|
+
/** What a started Synap Cloud sign-in remembers (sealed; never sent to the CP). */
|
|
37
|
+
export interface CloudTransaction {
|
|
38
|
+
v: 1;
|
|
39
|
+
state: string;
|
|
40
|
+
clientId: string;
|
|
41
|
+
/** The CP base it was sent to. */
|
|
42
|
+
cloudUrl: string;
|
|
43
|
+
next: string | null;
|
|
44
|
+
spaceHint: string | null;
|
|
45
|
+
prompt: "consent" | null;
|
|
46
|
+
createdAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CloudConnectConfig {
|
|
50
|
+
/** The Control Plane's base URL, e.g. https://api.synap.live. Unset = no Synap Cloud option. */
|
|
51
|
+
cloudUrl?: string | null;
|
|
52
|
+
/** Accept a CP on http://localhost (development). */
|
|
53
|
+
allowInsecureLoopback?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The CP base (no trailing slash), or a ConnectError (`misconfigured`). */
|
|
57
|
+
export function cloudBaseUrl(cloudUrl: string, opts: { allowInsecureLoopback?: boolean } = {}): string {
|
|
58
|
+
let url: URL;
|
|
59
|
+
try {
|
|
60
|
+
url = new URL(cloudUrl.trim());
|
|
61
|
+
} catch {
|
|
62
|
+
throw new ConnectError("misconfigured", "cloudUrl is not a URL");
|
|
63
|
+
}
|
|
64
|
+
const loopback = LOOPBACK_HOSTS.has(url.hostname);
|
|
65
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback && opts.allowInsecureLoopback)) {
|
|
66
|
+
throw new ConnectError("misconfigured", "cloudUrl must be https (http only on localhost in development)");
|
|
67
|
+
}
|
|
68
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
69
|
+
throw new ConnectError("misconfigured", "cloudUrl has no credentials, query or fragment");
|
|
70
|
+
}
|
|
71
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether "Continue with Synap Cloud" can be offered: a valid Control Plane URL is configured. */
|
|
75
|
+
export function cloudConnectAvailable(config?: CloudConnectConfig | null): boolean {
|
|
76
|
+
if (!config?.cloudUrl) return false;
|
|
77
|
+
try {
|
|
78
|
+
cloudBaseUrl(config.cloudUrl, config);
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface StartCloudConnectOptions extends CloudConnectConfig {
|
|
86
|
+
/** The app's client_id (its CIMD URL): the CP reads `initiate_login_uri` from it. */
|
|
87
|
+
clientId: string;
|
|
88
|
+
/** SERVER-ONLY app secret that seals the transaction. */
|
|
89
|
+
secret?: AppSecret;
|
|
90
|
+
/** A same-origin path to land on afterwards (validate it first: `safeNextPath`). */
|
|
91
|
+
next?: string | null;
|
|
92
|
+
/** A space to preselect on the pod's consent. */
|
|
93
|
+
spaceHint?: string | null;
|
|
94
|
+
/** Show the pod's consent again even if this browser is already connected to the pod the person picks. */
|
|
95
|
+
prompt?: "consent" | null;
|
|
96
|
+
/** @deprecated Ignored: the SDK always draws its own 32-byte state. */
|
|
97
|
+
state?: string;
|
|
98
|
+
now?: () => number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface StartedCloudConnect {
|
|
102
|
+
/** Redirect the browser here (303): the CP's pod chooser. */
|
|
103
|
+
url: string;
|
|
104
|
+
/** Store in an HttpOnly cookie until the CP returns. */
|
|
105
|
+
transaction: SealedCloudTransaction;
|
|
106
|
+
/** ms since epoch. */
|
|
107
|
+
expiresAt: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Start a Synap Cloud sign-in. Throws `cp_unavailable` at once when no CP URL
|
|
112
|
+
* is configured (as 1.3 always did), `misconfigured` without a secret.
|
|
113
|
+
*/
|
|
114
|
+
export function startCloudConnect(opts: StartCloudConnectOptions): Promise<StartedCloudConnect> {
|
|
115
|
+
if (!cloudConnectAvailable(opts)) {
|
|
116
|
+
throw new ConnectError("cp_unavailable", "cp: not configured — pass cloudUrl (the Control Plane URL) to offer Synap Cloud");
|
|
117
|
+
}
|
|
118
|
+
if (!opts.secret) throw new ConnectError("misconfigured", "startCloudConnect needs the app secret to seal its state");
|
|
119
|
+
const base = cloudBaseUrl(opts.cloudUrl!, opts);
|
|
120
|
+
const secret = opts.secret;
|
|
121
|
+
return (async () => {
|
|
122
|
+
const now = (opts.now ?? Date.now)();
|
|
123
|
+
const state = randomToken();
|
|
124
|
+
const url = new URL(`${base}/connect/pod`);
|
|
125
|
+
url.searchParams.set("client_id", opts.clientId);
|
|
126
|
+
url.searchParams.set("state", state);
|
|
127
|
+
const tx: CloudTransaction = {
|
|
128
|
+
v: 1,
|
|
129
|
+
state,
|
|
130
|
+
clientId: opts.clientId,
|
|
131
|
+
cloudUrl: base,
|
|
132
|
+
next: opts.next ?? null,
|
|
133
|
+
spaceHint: opts.spaceHint ?? null,
|
|
134
|
+
prompt: opts.prompt === "consent" ? "consent" : null,
|
|
135
|
+
createdAt: now,
|
|
136
|
+
};
|
|
137
|
+
const expiresAt = now + CLOUD_TRANSACTION_TTL_MS;
|
|
138
|
+
const transaction = (await seal(tx, { secret, purpose: "synap-connect/cloud", expiresAt })) as SealedCloudTransaction;
|
|
139
|
+
return { url: url.href, transaction, expiresAt };
|
|
140
|
+
})();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface AcceptCloudReturnOptions {
|
|
144
|
+
/** The URL the CP sent the browser back to (`?iss=…&state=…`). */
|
|
145
|
+
url: string | URL;
|
|
146
|
+
transaction: string | null | undefined;
|
|
147
|
+
clientId: string;
|
|
148
|
+
secret: AppSecret;
|
|
149
|
+
allowInsecureLoopback?: boolean;
|
|
150
|
+
now?: () => number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface CloudReturn {
|
|
154
|
+
/** The pod the person picked (canonical issuer), still to be discovered and validated. */
|
|
155
|
+
pod: string;
|
|
156
|
+
next: string | null;
|
|
157
|
+
spaceHint: string | null;
|
|
158
|
+
prompt: "consent" | null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Check the CP's return against the sealed transaction of THIS browser.
|
|
163
|
+
* Throws `expired_transaction` (none, foreign, tampered or older than 10
|
|
164
|
+
* minutes), `mismatch` (the state is not this browser's, or no pod is named),
|
|
165
|
+
* `denied` (the person cancelled on the chooser: `error=access_denied`, no
|
|
166
|
+
* `iss`; nothing is started), `invalid_pod_address`.
|
|
167
|
+
* `state` is compared BEFORE anything else in the query is used.
|
|
168
|
+
*/
|
|
169
|
+
export async function acceptCloudReturn(opts: AcceptCloudReturnOptions): Promise<CloudReturn> {
|
|
170
|
+
if (!opts.transaction) throw new ConnectError("expired_transaction", "No Synap Cloud sign-in in progress in this browser");
|
|
171
|
+
const opened = await unseal<CloudTransaction>(opts.transaction, {
|
|
172
|
+
secret: opts.secret,
|
|
173
|
+
purpose: "synap-connect/cloud",
|
|
174
|
+
now: (opts.now ?? Date.now)(),
|
|
175
|
+
});
|
|
176
|
+
if (!opened.ok) {
|
|
177
|
+
throw new ConnectError(
|
|
178
|
+
"expired_transaction",
|
|
179
|
+
opened.reason === "expired" ? "The Synap Cloud sign-in is older than 10 minutes" : "The Synap Cloud cookie is not one this app sealed",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
const tx = opened.value;
|
|
183
|
+
if (!tx || tx.v !== 1 || typeof tx.state !== "string") {
|
|
184
|
+
throw new ConnectError("expired_transaction", "The Synap Cloud cookie is not one this app sealed");
|
|
185
|
+
}
|
|
186
|
+
if (tx.clientId !== opts.clientId) {
|
|
187
|
+
throw new ConnectError("misconfigured", "The Synap Cloud sign-in was started for another client_id");
|
|
188
|
+
}
|
|
189
|
+
const params = new URL(opts.url).searchParams;
|
|
190
|
+
const state = params.get("state");
|
|
191
|
+
if (state === null || !STATE_RE.test(state) || !constantTimeEqual(state, tx.state)) {
|
|
192
|
+
throw new ConnectError("mismatch", "The Synap Cloud answer does not belong to the sign-in this browser started");
|
|
193
|
+
}
|
|
194
|
+
const error = params.get("error");
|
|
195
|
+
if (error !== null) {
|
|
196
|
+
throw new ConnectError(fromOAuthError(error, "authorize"), `Synap Cloud answered ${error}`, { oauthError: error });
|
|
197
|
+
}
|
|
198
|
+
const iss = params.get("iss");
|
|
199
|
+
if (!iss) throw new ConnectError("mismatch", "The Synap Cloud answer names no pod");
|
|
200
|
+
return {
|
|
201
|
+
pod: canonicalPodUrl(iss, opts),
|
|
202
|
+
next: tx.next,
|
|
203
|
+
spaceHint: tx.spaceHint,
|
|
204
|
+
prompt: tx.prompt === "consent" ? "consent" : null,
|
|
205
|
+
};
|
|
206
|
+
}
|