@synap-core/sdk 1.1.0 → 1.3.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 +138 -6
- package/dist/chunk-J7XHAAFJ.js +79 -0
- package/dist/chunk-J7XHAAFJ.js.map +1 -0
- package/dist/client-CVUT1Mcf.d.cts +253 -0
- package/dist/client-CVUT1Mcf.d.ts +253 -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 +1206 -0
- package/dist/connect.cjs.map +1 -0
- package/dist/connect.d.cts +442 -0
- package/dist/connect.d.ts +442 -0
- package/dist/connect.js +1092 -0
- package/dist/connect.js.map +1 -0
- package/dist/index.cjs +48 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +118 -7
- package/dist/index.d.ts +118 -7
- package/dist/index.js +41 -2
- package/dist/index.js.map +1 -1
- package/dist/public.cjs +627 -0
- package/dist/public.cjs.map +1 -0
- package/dist/public.d.cts +228 -0
- package/dist/public.d.ts +228 -0
- package/dist/public.js +578 -0
- package/dist/public.js.map +1 -0
- package/dist/testing.cjs +671 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +181 -0
- package/dist/testing.d.ts +181 -0
- package/dist/testing.js +614 -0
- package/dist/testing.js.map +1 -0
- package/dist/types-DpqPNaaa.d.cts +76 -0
- package/dist/types-DpqPNaaa.d.ts +76 -0
- package/package.json +16 -3
- package/src/client.ts +24 -7
- package/src/connect/auth.ts +405 -0
- package/src/connect/browser.ts +10 -0
- package/src/connect/client.ts +193 -0
- package/src/connect/discovery.ts +262 -0
- package/src/connect/encoding.ts +93 -0
- package/src/connect/errors.ts +115 -0
- package/src/connect/flow.ts +449 -0
- package/src/connect/guard.ts +25 -0
- package/src/connect/index.ts +59 -0
- package/src/connect/seal.ts +119 -0
- package/src/connect/store.ts +151 -0
- package/src/connect/types.ts +80 -0
- package/src/index.ts +13 -0
- package/src/public/client.ts +579 -0
- package/src/public/form-model.ts +210 -0
- package/src/public/index.ts +12 -0
- package/src/public/safe-markdown.ts +114 -0
- package/src/public/share-model.ts +47 -0
- package/src/public/site-model.ts +217 -0
- package/src/templates.ts +122 -0
- package/src/testing/auth-server.ts +380 -0
- package/src/testing/fixtures.ts +132 -0
- package/src/testing/index.ts +359 -0
- package/src/public.ts +0 -262
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createPodAppAuth(config)`: pod-app sign-in as Web-standard route handlers
|
|
3
|
+
* (`Request → Response`, so Next route handlers, Hono, Remix, workers) plus
|
|
4
|
+
* the `proxy.ts` refresh helper. The route files stay two lines:
|
|
5
|
+
*
|
|
6
|
+
* // app/api/synap/connect/route.ts
|
|
7
|
+
* export const POST = synap.handlers.connect;
|
|
8
|
+
*
|
|
9
|
+
* Handlers:
|
|
10
|
+
* GET <metadata> the app's client metadata document (its client_id URL)
|
|
11
|
+
* POST <connect> form { pod, next?, space? } → 303 to the pod's /authorize
|
|
12
|
+
* GET <callback> the pod's answer → sealed session cookie → 303 to `next`
|
|
13
|
+
* POST <disconnect> revoke at the pod (best effort), clear the cookie → 303
|
|
14
|
+
*
|
|
15
|
+
* `connect` and `disconnect` change state, so they are POST and same-origin
|
|
16
|
+
* only (Origin, else Sec-Fetch-Site): a third-party page cannot start a
|
|
17
|
+
* sign-in into an attacker's pod for a visitor (login CSRF) or sign them out.
|
|
18
|
+
* Failures redirect with `?error=<ConnectErrorCode>` and nothing else.
|
|
19
|
+
*/
|
|
20
|
+
import { safeNextPath, canonicalPodUrl } from "./discovery";
|
|
21
|
+
import { ConnectError, isConnectError } from "./errors";
|
|
22
|
+
import { serializeCookie, parseCookies } from "./encoding";
|
|
23
|
+
import { createPodClient, type PodClient, type PodClientOptions } from "./client";
|
|
24
|
+
import { assertPermissions, handleCallback, refreshConnection, revokeConnection, startConnect, TRANSACTION_TTL_MS } from "./flow";
|
|
25
|
+
import { assertServerRuntime } from "./guard";
|
|
26
|
+
import { assertAppSecret, type AppSecret } from "./seal";
|
|
27
|
+
import { applySetCookies, cookieNames, createSealedCookieStore, type ConnectionStore } from "./store";
|
|
28
|
+
import type { AppRequest, ClientMetadataDocument, PodConnection } from "./types";
|
|
29
|
+
|
|
30
|
+
export interface PodAppPaths {
|
|
31
|
+
/** The client metadata document; its absolute URL is the client_id. */
|
|
32
|
+
metadata: string;
|
|
33
|
+
connect: string;
|
|
34
|
+
callback: string;
|
|
35
|
+
disconnect: string;
|
|
36
|
+
/** The app's connect page (also the CIMD `initiate_login_uri`). */
|
|
37
|
+
connectPage: string;
|
|
38
|
+
/** Where a failed callback lands, with `?error=<code>`. */
|
|
39
|
+
resultPage: string;
|
|
40
|
+
/** Where a sign-in lands when it named no `next`. */
|
|
41
|
+
afterConnect: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_PATHS: PodAppPaths = {
|
|
45
|
+
metadata: "/.well-known/synap-client.json",
|
|
46
|
+
connect: "/api/synap/connect",
|
|
47
|
+
callback: "/api/synap/callback",
|
|
48
|
+
disconnect: "/api/synap/disconnect",
|
|
49
|
+
connectPage: "/connect",
|
|
50
|
+
resultPage: "/connect/result",
|
|
51
|
+
afterConnect: "/",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Refresh only inside the last 5 minutes of the access token's life. */
|
|
55
|
+
export const REFRESH_WINDOW_MS = 5 * 60 * 1000;
|
|
56
|
+
|
|
57
|
+
export interface PodAppAuthConfig {
|
|
58
|
+
/** The app's origin, e.g. https://studio.example.com (http only on localhost). */
|
|
59
|
+
appUrl: string;
|
|
60
|
+
/** Shown on the pod's consent screen (at most 100 characters). */
|
|
61
|
+
clientName: string;
|
|
62
|
+
/** What the app asks for, each with the reason the consent screen shows. */
|
|
63
|
+
requests: readonly AppRequest[];
|
|
64
|
+
/** Workspace-template slugs the consent may offer to create (`synap_templates`). */
|
|
65
|
+
templates?: readonly string[];
|
|
66
|
+
/** SERVER-ONLY secret, at least 32 characters (SYNAP_APP_SESSION_SECRET). Several to rotate. */
|
|
67
|
+
secret: AppSecret;
|
|
68
|
+
paths?: Partial<PodAppPaths>;
|
|
69
|
+
/** Defaults to the sealed session cookie. */
|
|
70
|
+
store?: ConnectionStore;
|
|
71
|
+
/** RFC 8707 resource asked for (default the Hub API). */
|
|
72
|
+
resource?: "api/hub" | "mcp" | null;
|
|
73
|
+
/** Accept pods on http://localhost (development against a local pod). */
|
|
74
|
+
allowInsecureLoopback?: boolean;
|
|
75
|
+
/** Developer log of every refused sign-in; messages never carry a token. Default: console.warn. */
|
|
76
|
+
onError?: (error: ConnectError, where: "connect" | "callback" | "disconnect" | "refresh") => void;
|
|
77
|
+
fetch?: typeof fetch;
|
|
78
|
+
now?: () => number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type RefreshStatus =
|
|
82
|
+
/** No connection in this request. */
|
|
83
|
+
| "none"
|
|
84
|
+
/** Prefetch or static asset: nothing refreshed (the connection is read as is). */
|
|
85
|
+
| "skipped"
|
|
86
|
+
/** Valid beyond the refresh window, or no refresh token to use. */
|
|
87
|
+
| "fresh"
|
|
88
|
+
/** Rotated: send `setCookies`, forward `cookieHeader`. */
|
|
89
|
+
| "refreshed"
|
|
90
|
+
/** The refresh failed (a lost rotation race, the pod busy) but the access token still works: keep it, set nothing. */
|
|
91
|
+
| "kept"
|
|
92
|
+
/** The access token is spent and cannot be renewed: connect again. */
|
|
93
|
+
| "expired"
|
|
94
|
+
/** The pod refused the refresh token (access removed): connect again. */
|
|
95
|
+
| "revoked"
|
|
96
|
+
/** The pod could not be reached and the access token is spent: try later. */
|
|
97
|
+
| "unavailable";
|
|
98
|
+
|
|
99
|
+
export interface RefreshOutcome {
|
|
100
|
+
status: RefreshStatus;
|
|
101
|
+
/** A usable connection, or null. */
|
|
102
|
+
connection: PodConnection | null;
|
|
103
|
+
/** Set-Cookie values to append to the response. */
|
|
104
|
+
setCookies: string[];
|
|
105
|
+
/** The request's Cookie header with the refreshed value, to forward to the render (null = unchanged). */
|
|
106
|
+
cookieHeader: string | null;
|
|
107
|
+
error?: ConnectError;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface PodAppAuth {
|
|
111
|
+
clientId: string;
|
|
112
|
+
redirectUri: string;
|
|
113
|
+
metadata: ClientMetadataDocument;
|
|
114
|
+
paths: PodAppPaths;
|
|
115
|
+
handlers: {
|
|
116
|
+
clientMetadata(request: Request): Promise<Response>;
|
|
117
|
+
connect(request: Request): Promise<Response>;
|
|
118
|
+
callback(request: Request): Promise<Response>;
|
|
119
|
+
disconnect(request: Request): Promise<Response>;
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* The stored connection (no refresh, no expiry check), or null. Takes the
|
|
123
|
+
* request, or just its Cookie header (a server component has no Request:
|
|
124
|
+
* pass `(await headers()).get("cookie")`).
|
|
125
|
+
*/
|
|
126
|
+
readConnection(source: Request | string | null): Promise<PodConnection | null>;
|
|
127
|
+
/** For `proxy.ts` / a route handler: rotate near expiry, lost-race safe. */
|
|
128
|
+
refreshIfNeeded(request: Request): Promise<RefreshOutcome>;
|
|
129
|
+
/** A Hub REST client for the connection. */
|
|
130
|
+
client(connection: PodConnection, opts?: PodClientOptions): PodClient;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
134
|
+
const TEMPLATE_SLUG_RE = /^[a-z0-9][a-z0-9._/-]{0,99}$/;
|
|
135
|
+
const MAX_METADATA_BYTES = 5 * 1024;
|
|
136
|
+
|
|
137
|
+
function appOriginOf(appUrl: string): { origin: string; secure: boolean } {
|
|
138
|
+
let url: URL;
|
|
139
|
+
try {
|
|
140
|
+
url = new URL(appUrl);
|
|
141
|
+
} catch {
|
|
142
|
+
throw new ConnectError("misconfigured", "appUrl is not a URL");
|
|
143
|
+
}
|
|
144
|
+
const secure = url.protocol === "https:";
|
|
145
|
+
if (!secure && !(url.protocol === "http:" && LOOPBACK.has(url.hostname))) {
|
|
146
|
+
throw new ConnectError("misconfigured", "appUrl must be https (http only on localhost)");
|
|
147
|
+
}
|
|
148
|
+
if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
|
|
149
|
+
throw new ConnectError("misconfigured", "appUrl is an origin: no path, query, fragment or credentials");
|
|
150
|
+
}
|
|
151
|
+
return { origin: url.origin, secure };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function assertPath(name: string, path: string): void {
|
|
155
|
+
if (!path.startsWith("/") || path.startsWith("//") || /[?#\\]/.test(path)) {
|
|
156
|
+
throw new ConnectError("misconfigured", `paths.${name} must be an absolute path on the app`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The CIMD document (spec 17 §6.1), validated the way the pod will validate it. */
|
|
161
|
+
export function buildClientMetadata(opts: {
|
|
162
|
+
appUrl: string;
|
|
163
|
+
clientName: string;
|
|
164
|
+
requests: readonly AppRequest[];
|
|
165
|
+
templates?: readonly string[];
|
|
166
|
+
paths?: Partial<PodAppPaths>;
|
|
167
|
+
}): ClientMetadataDocument {
|
|
168
|
+
const { origin } = appOriginOf(opts.appUrl);
|
|
169
|
+
const paths = { ...DEFAULT_PATHS, ...opts.paths };
|
|
170
|
+
for (const [name, path] of Object.entries(paths)) assertPath(name, path);
|
|
171
|
+
const clientName = opts.clientName.trim();
|
|
172
|
+
if (!clientName || clientName.length > 100) throw new ConnectError("misconfigured", "clientName must be 1-100 characters");
|
|
173
|
+
assertPermissions(opts.requests.map((r) => r.permission));
|
|
174
|
+
for (const r of opts.requests) {
|
|
175
|
+
if (r.permission.includes("*")) throw new ConnectError("misconfigured", "a permission cannot contain *");
|
|
176
|
+
if (typeof r.reason !== "string" || !r.reason.trim() || r.reason.length > 200) {
|
|
177
|
+
throw new ConnectError("misconfigured", `the reason for ${r.permission} must be 1-200 characters`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const templates = [...new Set(opts.templates ?? [])];
|
|
181
|
+
if (templates.length > 8 || templates.some((t) => !TEMPLATE_SLUG_RE.test(t))) {
|
|
182
|
+
throw new ConnectError("misconfigured", "at most 8 template slugs (lowercase letters, digits, . _ / -)");
|
|
183
|
+
}
|
|
184
|
+
const doc: ClientMetadataDocument = {
|
|
185
|
+
client_id: `${origin}${paths.metadata}`,
|
|
186
|
+
client_name: clientName,
|
|
187
|
+
client_uri: origin,
|
|
188
|
+
redirect_uris: [`${origin}${paths.callback}`],
|
|
189
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
190
|
+
response_types: ["code"],
|
|
191
|
+
token_endpoint_auth_method: "none",
|
|
192
|
+
initiate_login_uri: `${origin}${paths.connectPage}`,
|
|
193
|
+
synap_requests: opts.requests.map((r) => ({ permission: r.permission, reason: r.reason.trim() })),
|
|
194
|
+
...(templates.length > 0 ? { synap_templates: templates } : {}),
|
|
195
|
+
};
|
|
196
|
+
if (new TextEncoder().encode(JSON.stringify(doc)).length > MAX_METADATA_BYTES) {
|
|
197
|
+
throw new ConnectError("misconfigured", "the client metadata document exceeds the pod's 5 KB limit");
|
|
198
|
+
}
|
|
199
|
+
return doc;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function redirect(location: string, setCookies: readonly string[] = []): Response {
|
|
203
|
+
const headers = new Headers({ Location: location, "Cache-Control": "no-store" });
|
|
204
|
+
for (const c of setCookies) headers.append("Set-Cookie", c);
|
|
205
|
+
return new Response(null, { status: 303, headers });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function withQuery(origin: string, path: string, params: Record<string, string | null | undefined>): string {
|
|
209
|
+
const url = new URL(path, origin);
|
|
210
|
+
for (const [k, v] of Object.entries(params)) if (v) url.searchParams.set(k, v);
|
|
211
|
+
return url.href;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A state-changing request must come from the app's own pages. */
|
|
215
|
+
function sameOrigin(request: Request, origin: string): boolean {
|
|
216
|
+
const from = request.headers.get("origin");
|
|
217
|
+
if (from !== null) return from === origin;
|
|
218
|
+
return request.headers.get("sec-fetch-site") === "same-origin";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function formField(form: FormData | null, name: string): string | null {
|
|
222
|
+
const v = form?.get(name);
|
|
223
|
+
return typeof v === "string" ? v : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function readForm(request: Request): Promise<FormData | null> {
|
|
227
|
+
try {
|
|
228
|
+
return await request.formData();
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isPrefetchOrAsset(request: Request): boolean {
|
|
235
|
+
const h = request.headers;
|
|
236
|
+
if (h.get("next-router-prefetch") || h.get("x-middleware-prefetch")) return true;
|
|
237
|
+
if (/prefetch/i.test(h.get("purpose") ?? "") || /prefetch/i.test(h.get("sec-purpose") ?? "")) return true;
|
|
238
|
+
const path = new URL(request.url).pathname;
|
|
239
|
+
return path.startsWith("/_next/") || /\.[A-Za-z0-9]{1,8}$/.test(path);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function createPodAppAuth(config: PodAppAuthConfig): PodAppAuth {
|
|
243
|
+
assertServerRuntime();
|
|
244
|
+
assertAppSecret(config.secret);
|
|
245
|
+
const { origin, secure } = appOriginOf(config.appUrl);
|
|
246
|
+
const paths: PodAppPaths = { ...DEFAULT_PATHS, ...config.paths };
|
|
247
|
+
const metadata = buildClientMetadata({ ...config, paths });
|
|
248
|
+
const clientId = metadata.client_id;
|
|
249
|
+
const redirectUri = metadata.redirect_uris[0];
|
|
250
|
+
const permissions = config.requests.map((r) => r.permission);
|
|
251
|
+
const names = cookieNames(secure);
|
|
252
|
+
const now = config.now ?? Date.now;
|
|
253
|
+
const store = config.store ?? createSealedCookieStore({ secret: config.secret, secure, now });
|
|
254
|
+
const flow = { fetch: config.fetch, now: config.now };
|
|
255
|
+
const report =
|
|
256
|
+
config.onError ??
|
|
257
|
+
((err: ConnectError, where: string) => {
|
|
258
|
+
console.warn(`[synap/connect] ${where}: ${err.code}: ${err.message}`);
|
|
259
|
+
});
|
|
260
|
+
const fail = (err: unknown, where: "connect" | "callback" | "disconnect" | "refresh"): ConnectError => {
|
|
261
|
+
const e = isConnectError(err) ? err : new ConnectError("server_error", err instanceof Error ? err.message : "Unexpected failure");
|
|
262
|
+
report(e, where);
|
|
263
|
+
return e;
|
|
264
|
+
};
|
|
265
|
+
const clearTx = serializeCookie(names.transaction, "", { maxAgeSec: 0, secure });
|
|
266
|
+
|
|
267
|
+
async function clientMetadata(): Promise<Response> {
|
|
268
|
+
return new Response(JSON.stringify(metadata), {
|
|
269
|
+
status: 200,
|
|
270
|
+
headers: { "Content-Type": "application/json", "Cache-Control": "public, max-age=300" },
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function connect(request: Request): Promise<Response> {
|
|
275
|
+
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
|
|
276
|
+
if (!sameOrigin(request, origin)) return new Response("Forbidden", { status: 403 });
|
|
277
|
+
const form = await readForm(request);
|
|
278
|
+
const pod = formField(form, "pod")?.slice(0, 512) ?? "";
|
|
279
|
+
const next = safeNextPath(formField(form, "next"), origin) ?? paths.afterConnect;
|
|
280
|
+
const space = formField(form, "space");
|
|
281
|
+
try {
|
|
282
|
+
// A returning person already connected to this pod goes straight on.
|
|
283
|
+
const existing = await store.read(request);
|
|
284
|
+
if (existing && existing.accessExpiresAt > now() && existing.podUrl === canonicalPodUrl(pod, config)) {
|
|
285
|
+
return redirect(new URL(next, origin).href);
|
|
286
|
+
}
|
|
287
|
+
const started = await startConnect({
|
|
288
|
+
...flow,
|
|
289
|
+
pod,
|
|
290
|
+
clientId,
|
|
291
|
+
redirectUri,
|
|
292
|
+
permissions,
|
|
293
|
+
secret: config.secret,
|
|
294
|
+
resource: config.resource,
|
|
295
|
+
spaceHint: space && /^[A-Za-z0-9_-]{1,64}$/.test(space) ? space : null,
|
|
296
|
+
next,
|
|
297
|
+
allowInsecureLoopback: config.allowInsecureLoopback,
|
|
298
|
+
});
|
|
299
|
+
return redirect(started.authorizeUrl, [
|
|
300
|
+
serializeCookie(names.transaction, started.transaction, { maxAgeSec: TRANSACTION_TTL_MS / 1000, secure }),
|
|
301
|
+
]);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
const e = fail(err, "connect");
|
|
304
|
+
return redirect(withQuery(origin, paths.connectPage, { error: e.code, pod }));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function callback(request: Request): Promise<Response> {
|
|
309
|
+
if (request.method !== "GET") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET" } });
|
|
310
|
+
const transaction = parseCookies(request.headers.get("cookie")).get(names.transaction) ?? null;
|
|
311
|
+
try {
|
|
312
|
+
const { connection, next } = await handleCallback({
|
|
313
|
+
...flow,
|
|
314
|
+
callbackUrl: request.url,
|
|
315
|
+
transaction,
|
|
316
|
+
clientId,
|
|
317
|
+
redirectUri,
|
|
318
|
+
secret: config.secret,
|
|
319
|
+
});
|
|
320
|
+
const cookies = await store.write(connection, request);
|
|
321
|
+
return redirect(new URL(next ?? paths.afterConnect, origin).href, [clearTx, ...cookies]);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
const e = fail(err, "callback");
|
|
324
|
+
return redirect(withQuery(origin, paths.resultPage, { error: e.code }), [clearTx]);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function disconnect(request: Request): Promise<Response> {
|
|
329
|
+
if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST" } });
|
|
330
|
+
if (!sameOrigin(request, origin)) return new Response("Forbidden", { status: 403 });
|
|
331
|
+
const next = safeNextPath(formField(await readForm(request), "next"), origin) ?? paths.connectPage;
|
|
332
|
+
const connection = await store.read(request);
|
|
333
|
+
if (connection) {
|
|
334
|
+
const { revoked } = await revokeConnection(connection, flow);
|
|
335
|
+
if (!revoked) report(new ConnectError("pod_unreachable", "The pod did not confirm the revocation; the cookie is cleared anyway"), "disconnect");
|
|
336
|
+
}
|
|
337
|
+
return redirect(new URL(next, origin).href, await store.clear(request));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function refreshIfNeeded(request: Request): Promise<RefreshOutcome> {
|
|
341
|
+
const connection = await store.read(request);
|
|
342
|
+
const unchanged = (status: RefreshStatus, conn: PodConnection | null, error?: ConnectError): RefreshOutcome => ({
|
|
343
|
+
status,
|
|
344
|
+
connection: conn,
|
|
345
|
+
setCookies: [],
|
|
346
|
+
cookieHeader: null,
|
|
347
|
+
...(error ? { error } : {}),
|
|
348
|
+
});
|
|
349
|
+
if (!connection) return unchanged("none", null);
|
|
350
|
+
if (isPrefetchOrAsset(request)) return unchanged("skipped", connection);
|
|
351
|
+
const t = now();
|
|
352
|
+
const accessLives = connection.accessExpiresAt > t;
|
|
353
|
+
if (connection.accessExpiresAt - t > REFRESH_WINDOW_MS) return unchanged("fresh", connection);
|
|
354
|
+
if (!connection.refreshToken) {
|
|
355
|
+
// The pod issued none (before PB3): the connection ends with its access token.
|
|
356
|
+
return accessLives ? unchanged("fresh", connection) : unchanged("expired", null, new ConnectError("expired", "No refresh token; connect again"));
|
|
357
|
+
}
|
|
358
|
+
let next: PodConnection;
|
|
359
|
+
try {
|
|
360
|
+
next = await refreshConnection(connection, flow);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
const e = fail(err, "refresh");
|
|
363
|
+
// LOST-RACE SAFE: another instance may have redeemed the token at the
|
|
364
|
+
// same moment; the pod refuses the concurrent loser without a family
|
|
365
|
+
// revoke (PB3), and the access token we hold is still good. Keep it and
|
|
366
|
+
// set NOTHING, so the winner's cookie stands. (Within one process the
|
|
367
|
+
// race never reaches the pod: `refreshConnection` is single-flight.)
|
|
368
|
+
// Never clear the cookie here either: a clear racing the winner's
|
|
369
|
+
// Set-Cookie would sign the person out.
|
|
370
|
+
if (accessLives) return unchanged("kept", connection, e);
|
|
371
|
+
if (e.code === "revoked" || e.code === "expired") return unchanged(e.code, null, e);
|
|
372
|
+
return unchanged("unavailable", null, e);
|
|
373
|
+
}
|
|
374
|
+
// PERSIST BEFORE USE: the old refresh token is spent now; only a stored
|
|
375
|
+
// successor keeps the connection. If it cannot be stored, the new tokens
|
|
376
|
+
// are not handed out, and the stored (spent) one is cleared so no later
|
|
377
|
+
// request replays it (a replay revokes the family and the live key).
|
|
378
|
+
let setCookies: string[];
|
|
379
|
+
try {
|
|
380
|
+
setCookies = await store.write(next, request);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
const e = fail(err, "refresh");
|
|
383
|
+
const cleared = await store.clear(request).catch(() => [] as string[]);
|
|
384
|
+
return { status: "expired", connection: null, setCookies: cleared, cookieHeader: null, error: e };
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
status: "refreshed",
|
|
388
|
+
connection: next,
|
|
389
|
+
setCookies,
|
|
390
|
+
cookieHeader: applySetCookies(request.headers.get("cookie"), setCookies),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
clientId,
|
|
396
|
+
redirectUri,
|
|
397
|
+
metadata,
|
|
398
|
+
paths,
|
|
399
|
+
handlers: { clientMetadata, connect, callback, disconnect },
|
|
400
|
+
readConnection: (source) =>
|
|
401
|
+
store.read(source instanceof Request ? source : new Request(origin, { headers: source ? { cookie: source } : {} })),
|
|
402
|
+
refreshIfNeeded,
|
|
403
|
+
client: (connection, opts) => createPodClient(connection, { fetch: config.fetch, now: config.now, ...opts }),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a BROWSER bundle gets for `@synap-core/sdk/connect` (the `browser`
|
|
3
|
+
* export condition): no exports, and a throw on load. See ./guard.ts.
|
|
4
|
+
*/
|
|
5
|
+
throw new Error(
|
|
6
|
+
"@synap-core/sdk/connect is server-only: it holds the pod's tokens and the app secret. " +
|
|
7
|
+
"Import it from route handlers, proxy.ts or server code, never from a client component.",
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
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 } 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
|
+
export interface PodClientOptions {
|
|
53
|
+
fetch?: typeof fetch;
|
|
54
|
+
now?: () => number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PodClient {
|
|
58
|
+
podUrl: string;
|
|
59
|
+
user: PodConnection["user"];
|
|
60
|
+
grant: PodGrant[];
|
|
61
|
+
/** Whether the connection was granted `permission` (in `workspaceId` when given). */
|
|
62
|
+
can(permission: string, workspaceId?: string): boolean;
|
|
63
|
+
get<T = unknown>(path: string, query?: Record<string, QueryValue>): Promise<PodResult<T>>;
|
|
64
|
+
post<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
|
|
65
|
+
patch<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
|
|
66
|
+
put<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
|
|
67
|
+
delete<T = unknown>(path: string, body?: unknown): Promise<PodWriteResult<T>>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether `connection` was granted `permission` (in `workspaceId` when given). Exact match: grants carry no wildcard. */
|
|
71
|
+
export function can(connection: Pick<PodConnection, "grant">, permission: string, workspaceId?: string): boolean {
|
|
72
|
+
return connection.grant.some((g) => g.permission === permission && (workspaceId === undefined || g.workspaceId === workspaceId));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SAFE_PATH = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/;
|
|
76
|
+
|
|
77
|
+
function hubUrl(podUrl: string, path: string, query?: Record<string, QueryValue>): URL {
|
|
78
|
+
if (!SAFE_PATH.test(path) || /(^|\/)\.\.?(\/|$)/.test(path) || path.includes("\\")) {
|
|
79
|
+
throw new TypeError(`Not a Hub API path: ${path}`);
|
|
80
|
+
}
|
|
81
|
+
const url = new URL(`${podUrl}/api/hub${path}`);
|
|
82
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
83
|
+
if (value === undefined || value === null) continue;
|
|
84
|
+
for (const v of Array.isArray(value) ? value : [value]) url.searchParams.append(key, String(v));
|
|
85
|
+
}
|
|
86
|
+
return url;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function errorFor(status: number, body: unknown, retryAfter: string | null, now: number): PodError {
|
|
90
|
+
const obj = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
|
91
|
+
const detail = typeof obj.error === "string" ? obj.error : typeof obj.message === "string" ? obj.message : undefined;
|
|
92
|
+
const base = { status, ...(detail !== undefined ? { detail } : {}) };
|
|
93
|
+
if (status === 401) return { code: "unauthorized", ...base };
|
|
94
|
+
if (status === 403) return { code: obj.code === "GRANT_DOOR_UNSUPPORTED" ? "door_unsupported" : "forbidden", ...base };
|
|
95
|
+
if (status === 404) return { code: "not_found", ...base };
|
|
96
|
+
if (status === 409) return { code: "conflict", ...base };
|
|
97
|
+
if (status === 400 || status === 422) return { code: "invalid", ...base };
|
|
98
|
+
if (status === 429) {
|
|
99
|
+
const retryAfterSec = retryAfterSeconds(retryAfter, now);
|
|
100
|
+
return { code: "rate_limited", ...base, ...(retryAfterSec !== undefined ? { retryAfterSec } : {}) };
|
|
101
|
+
}
|
|
102
|
+
if (status === 502 || status === 503 || status === 504) return { code: "pod_unreachable", ...base };
|
|
103
|
+
return { code: "server", ...base };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Where to review a proposal: the pod's answer, resolved against the pod and
|
|
108
|
+
* kept only on the pod's own origin; else the pod's deep link `/open/<id>`.
|
|
109
|
+
*/
|
|
110
|
+
export function reviewUrlFor(podUrl: string, reviewUrl: unknown, proposalId: string | null): string {
|
|
111
|
+
if (typeof reviewUrl === "string" && reviewUrl) {
|
|
112
|
+
try {
|
|
113
|
+
const url = new URL(reviewUrl, `${podUrl}/`);
|
|
114
|
+
if (url.origin === new URL(podUrl).origin) return url.href;
|
|
115
|
+
} catch {
|
|
116
|
+
// fall through to the deep link
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return proposalId ? `${podUrl}/open/${encodeURIComponent(proposalId)}` : podUrl;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createPodClient(connection: PodConnection, opts: PodClientOptions = {}): PodClient {
|
|
123
|
+
const now = opts.now ?? Date.now;
|
|
124
|
+
const doFetch = () => opts.fetch ?? fetch;
|
|
125
|
+
|
|
126
|
+
async function request(method: string, path: string, body: unknown, query?: Record<string, QueryValue>): Promise<PodResult<unknown>> {
|
|
127
|
+
const url = hubUrl(connection.podUrl, path, query);
|
|
128
|
+
if (connection.accessExpiresAt <= now()) return { ok: false, error: { code: "expired", status: null } };
|
|
129
|
+
const headers: Record<string, string> = { Accept: "application/json", Authorization: `Bearer ${connection.accessToken}` };
|
|
130
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
131
|
+
let res: Response;
|
|
132
|
+
try {
|
|
133
|
+
res = await doFetch()(url.href, {
|
|
134
|
+
method,
|
|
135
|
+
headers,
|
|
136
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
137
|
+
redirect: "manual",
|
|
138
|
+
});
|
|
139
|
+
} catch {
|
|
140
|
+
return { ok: false, error: { code: "pod_unreachable", status: null } };
|
|
141
|
+
}
|
|
142
|
+
let data: unknown = null;
|
|
143
|
+
const text = await res.text().catch(() => "");
|
|
144
|
+
if (text) {
|
|
145
|
+
try {
|
|
146
|
+
data = JSON.parse(text);
|
|
147
|
+
} catch {
|
|
148
|
+
data = null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (res.status >= 200 && res.status < 300) return { ok: true, data };
|
|
152
|
+
return { ok: false, error: errorFor(res.status, data, res.headers.get("retry-after"), now()) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function write<T>(method: string, path: string, body: unknown): Promise<PodWriteResult<T>> {
|
|
156
|
+
const result = await request(method, path, body);
|
|
157
|
+
if (!result.ok) return result;
|
|
158
|
+
const obj = result.data && typeof result.data === "object" ? (result.data as Record<string, unknown>) : {};
|
|
159
|
+
const proposalId = typeof obj.proposalId === "string" && obj.proposalId ? obj.proposalId : null;
|
|
160
|
+
if (obj.status === "proposed" || (obj.status === "pending" && proposalId)) {
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
status: "proposed",
|
|
164
|
+
proposalId,
|
|
165
|
+
reviewUrl: reviewUrlFor(connection.podUrl, obj.reviewUrl, proposalId),
|
|
166
|
+
data: result.data as T,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return { ok: true, status: "applied", data: result.data as T };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
podUrl: connection.podUrl,
|
|
174
|
+
user: connection.user,
|
|
175
|
+
grant: connection.grant,
|
|
176
|
+
can: (permission, workspaceId) => can(connection, permission, workspaceId),
|
|
177
|
+
get<T>(path: string, query?: Record<string, QueryValue>) {
|
|
178
|
+
return request("GET", path, undefined, query) as Promise<PodResult<T>>;
|
|
179
|
+
},
|
|
180
|
+
post<T>(path: string, body?: unknown) {
|
|
181
|
+
return write<T>("POST", path, body);
|
|
182
|
+
},
|
|
183
|
+
patch<T>(path: string, body?: unknown) {
|
|
184
|
+
return write<T>("PATCH", path, body);
|
|
185
|
+
},
|
|
186
|
+
put<T>(path: string, body?: unknown) {
|
|
187
|
+
return write<T>("PUT", path, body);
|
|
188
|
+
},
|
|
189
|
+
delete<T>(path: string, body?: unknown) {
|
|
190
|
+
return write<T>("DELETE", path, body);
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|