@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.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Sealed values: AES-256-GCM under a key derived (HKDF-SHA-256) from the app
3
+ * secret and a PURPOSE. The purpose is both the HKDF `info` and the GCM
4
+ * additional data, so a value sealed as a sign-in transaction never opens as a
5
+ * session, and the reverse. Any change to the sealed text, a wrong secret or a
6
+ * wrong purpose fails GCM authentication: the value reads as absent.
7
+ *
8
+ * v1.<base64url(iv[12] ‖ ciphertext ‖ tag[16])>
9
+ *
10
+ * The plaintext carries its own expiry (`exp`, ms), checked after decryption,
11
+ * so a cookie kept past its Max-Age by a hostile client still expires.
12
+ */
13
+ import { ConnectError } from "./errors";
14
+ import { base64url, fromBase64url, fromUtf8, randomBytes, utf8 } from "./encoding";
15
+
16
+ export type SealPurpose = "synap-connect/transaction" | "synap-connect/session" | "synap-connect/cloud";
17
+
18
+ /** The app secret: one value, or several to rotate (the first seals, all open). */
19
+ export type AppSecret = string | readonly string[];
20
+
21
+ const VERSION = "v1";
22
+ const IV_BYTES = 12;
23
+ const TAG_BYTES = 16;
24
+ export const MIN_SECRET_LENGTH = 32;
25
+ const SALT = utf8("synap-connect/v1");
26
+
27
+ const keyCache = new Map<string, Promise<CryptoKey>>();
28
+
29
+ function secretsOf(secret: AppSecret): readonly string[] {
30
+ const list = typeof secret === "string" ? [secret] : secret;
31
+ if (list.length === 0 || list.some((s) => typeof s !== "string" || s.length < MIN_SECRET_LENGTH)) {
32
+ throw new ConnectError(
33
+ "misconfigured",
34
+ `The app session secret must be at least ${MIN_SECRET_LENGTH} characters (set SYNAP_APP_SESSION_SECRET on the server).`,
35
+ );
36
+ }
37
+ return list;
38
+ }
39
+
40
+ /** Fail fast at setup when the secret is unusable. */
41
+ export function assertAppSecret(secret: AppSecret): void {
42
+ secretsOf(secret);
43
+ }
44
+
45
+ function keyFor(secret: string, purpose: SealPurpose): Promise<CryptoKey> {
46
+ const cacheKey = `${purpose}\u0000${secret}`;
47
+ let key = keyCache.get(cacheKey);
48
+ if (!key) {
49
+ key = crypto.subtle
50
+ .importKey("raw", utf8(secret), "HKDF", false, ["deriveKey"])
51
+ .then((base) =>
52
+ crypto.subtle.deriveKey(
53
+ { name: "HKDF", hash: "SHA-256", salt: SALT, info: utf8(purpose) },
54
+ base,
55
+ { name: "AES-GCM", length: 256 },
56
+ false,
57
+ ["encrypt", "decrypt"],
58
+ ),
59
+ );
60
+ keyCache.set(cacheKey, key);
61
+ }
62
+ return key;
63
+ }
64
+
65
+ export async function seal(value: unknown, opts: { secret: AppSecret; purpose: SealPurpose; expiresAt: number }): Promise<string> {
66
+ const [current] = secretsOf(opts.secret);
67
+ const iv = randomBytes(IV_BYTES);
68
+ const plaintext = utf8(JSON.stringify({ exp: opts.expiresAt, data: value }));
69
+ const ciphertext = await crypto.subtle.encrypt(
70
+ { name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
71
+ await keyFor(current, opts.purpose),
72
+ plaintext,
73
+ );
74
+ const out = new Uint8Array(IV_BYTES + ciphertext.byteLength);
75
+ out.set(iv, 0);
76
+ out.set(new Uint8Array(ciphertext), IV_BYTES);
77
+ return `${VERSION}.${base64url(out)}`;
78
+ }
79
+
80
+ export type Opened<T> = { ok: true; value: T; expiresAt: number } | { ok: false; reason: "invalid" | "expired" };
81
+
82
+ /**
83
+ * `invalid` = not ours (malformed, tampered, other secret, other purpose);
84
+ * `expired` = ours, but past its `exp`.
85
+ */
86
+ export async function unseal<T = unknown>(
87
+ sealed: string,
88
+ opts: { secret: AppSecret; purpose: SealPurpose; now: number },
89
+ ): Promise<Opened<T>> {
90
+ const secrets = secretsOf(opts.secret);
91
+ const dot = sealed.indexOf(".");
92
+ if (dot < 0 || sealed.slice(0, dot) !== VERSION) return { ok: false, reason: "invalid" };
93
+ const bytes = fromBase64url(sealed.slice(dot + 1));
94
+ if (!bytes || bytes.length < IV_BYTES + TAG_BYTES) return { ok: false, reason: "invalid" };
95
+ const iv = bytes.slice(0, IV_BYTES);
96
+ const ciphertext = bytes.slice(IV_BYTES);
97
+ for (const secret of secrets) {
98
+ let plaintext: ArrayBuffer;
99
+ try {
100
+ plaintext = await crypto.subtle.decrypt(
101
+ { name: "AES-GCM", iv, additionalData: utf8(opts.purpose) },
102
+ await keyFor(secret, opts.purpose),
103
+ ciphertext,
104
+ );
105
+ } catch {
106
+ continue; // not this secret (or tampered): try the next, then refuse
107
+ }
108
+ let parsed: { exp?: unknown; data?: unknown };
109
+ try {
110
+ parsed = JSON.parse(fromUtf8(new Uint8Array(plaintext))) as { exp?: unknown; data?: unknown };
111
+ } catch {
112
+ return { ok: false, reason: "invalid" };
113
+ }
114
+ if (typeof parsed.exp !== "number") return { ok: false, reason: "invalid" };
115
+ if (parsed.exp <= opts.now) return { ok: false, reason: "expired" };
116
+ return { ok: true, value: parsed.data as T, expiresAt: parsed.exp };
117
+ }
118
+ return { ok: false, reason: "invalid" };
119
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Where a PodConnection lives between requests. The default keeps it in the
3
+ * browser as a SEALED cookie (AES-GCM under the app secret, HttpOnly, Secure
4
+ * on https, SameSite=Lax, Path=/, `__Host-` prefix on https): the browser holds
5
+ * ciphertext it cannot read or forge, the pod token never reaches JS.
6
+ *
7
+ * Bring your own (a database, a KV) by implementing `ConnectionStore`; it
8
+ * answers with the Set-Cookie values to send (e.g. its own session id).
9
+ */
10
+ import { canonicalPodUrl } from "./discovery";
11
+ import { base64url, fromBase64url, fromUtf8, parseCookies, serializeCookie, utf8 } from "./encoding";
12
+ import { ConnectError } from "./errors";
13
+ import { seal, unseal, type AppSecret } from "./seal";
14
+ import type { PodConnection, PodGrant } from "./types";
15
+
16
+ export interface ConnectionStore {
17
+ /** The request's connection, or null (absent, tampered, foreign, expired). */
18
+ read(request: Request): Promise<PodConnection | null>;
19
+ /** Persist; returns the Set-Cookie header values to send. */
20
+ write(connection: PodConnection, request: Request): Promise<string[]>;
21
+ /** Forget; returns the Set-Cookie header values to send. */
22
+ clear(request: Request): Promise<string[]>;
23
+ }
24
+
25
+ export interface CookieNames {
26
+ session: string;
27
+ transaction: string;
28
+ /** A Synap Cloud sign-in in progress (sealed state, 10 minutes). */
29
+ cloud: string;
30
+ /** "This browser was connected to <pod>": the pod's URL only, no secret (`markerCookie`). */
31
+ marker: string;
32
+ }
33
+
34
+ /** `__Host-` names on https (Secure, Path=/, no Domain: bound to this exact host). */
35
+ export function cookieNames(secure: boolean): CookieNames {
36
+ return secure
37
+ ? { session: "__Host-synap_session", transaction: "__Host-synap_tx", cloud: "__Host-synap_cloud", marker: "__Host-synap_seen" }
38
+ : { session: "synap_session", transaction: "synap_tx", cloud: "synap_cloud", marker: "synap_seen" };
39
+ }
40
+
41
+ /** The marker outlives the connection: as long as browsers keep a cookie (400 days). */
42
+ export const MARKER_TTL_SEC = 400 * 24 * 60 * 60;
43
+
44
+ /**
45
+ * The marker cookie: which pod this browser was connected to, so a connection
46
+ * that lapsed (idle past the refresh token's life, its sealed cookie gone)
47
+ * reads as "expired, connect again to <pod>" rather than "never connected".
48
+ * It holds the pod's public URL and NOTHING else: no token, no user, nothing
49
+ * sealed. A forged value can only pre-fill the address the person confirms.
50
+ */
51
+ export function markerCookie(podUrl: string, opts: { secure: boolean; name?: string }): string {
52
+ return serializeCookie(opts.name ?? cookieNames(opts.secure).marker, base64url(utf8(podUrl)), {
53
+ maxAgeSec: MARKER_TTL_SEC,
54
+ secure: opts.secure,
55
+ });
56
+ }
57
+
58
+ /** The marker's pod URL, or null (absent, or not a canonical pod address). */
59
+ export function readMarker(
60
+ cookieHeader: string | null,
61
+ opts: { secure: boolean; name?: string; allowInsecureLoopback?: boolean },
62
+ ): string | null {
63
+ const raw = parseCookies(cookieHeader).get(opts.name ?? cookieNames(opts.secure).marker);
64
+ const bytes = raw ? fromBase64url(raw) : null;
65
+ if (!bytes || bytes.length > 512) return null;
66
+ const podUrl = fromUtf8(bytes);
67
+ try {
68
+ return canonicalPodUrl(podUrl, opts) === podUrl ? podUrl : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /** Browsers cap a cookie near 4096 bytes; a big grant spans several. */
75
+ const CHUNK_SIZE = 3800;
76
+ const MAX_CHUNKS = 4;
77
+
78
+ function chunkName(name: string, i: number): string {
79
+ return i === 0 ? name : `${name}.${i}`;
80
+ }
81
+
82
+ /** The sealed form: the grant grouped per space (it repeats the space otherwise). */
83
+ interface StoredConnection extends Omit<PodConnection, "grant"> {
84
+ g: Array<[workspaceId: string, workspaceName: string, permissions: string[]]>;
85
+ }
86
+
87
+ function pack(c: PodConnection): StoredConnection {
88
+ const bySpace = new Map<string, [string, string, string[]]>();
89
+ for (const g of c.grant) {
90
+ const row = bySpace.get(g.workspaceId) ?? [g.workspaceId, g.workspaceName, []];
91
+ row[2].push(g.permission);
92
+ bySpace.set(g.workspaceId, row);
93
+ }
94
+ const { grant: _grant, ...rest } = c;
95
+ return { ...rest, g: [...bySpace.values()] };
96
+ }
97
+
98
+ function unpack(s: StoredConnection): PodConnection | null {
99
+ if (!s || typeof s.podUrl !== "string" || typeof s.accessToken !== "string" || !Array.isArray(s.g)) return null;
100
+ const { g, ...rest } = s;
101
+ const grant: PodGrant[] = g.flatMap(([workspaceId, workspaceName, permissions]) =>
102
+ permissions.map((permission) => ({ permission, workspaceId, workspaceName })),
103
+ );
104
+ return { ...rest, grant };
105
+ }
106
+
107
+ /** When the stored value stops being useful. */
108
+ export function connectionExpiresAt(c: PodConnection): number {
109
+ return c.refreshToken && c.refreshExpiresAt !== undefined ? Math.max(c.refreshExpiresAt, c.accessExpiresAt) : c.accessExpiresAt;
110
+ }
111
+
112
+ export interface SealedCookieStoreOptions {
113
+ secret: AppSecret;
114
+ /** True on https (the default when the app URL is https). */
115
+ secure: boolean;
116
+ name?: string;
117
+ now?: () => number;
118
+ }
119
+
120
+ export function createSealedCookieStore(opts: SealedCookieStoreOptions): ConnectionStore {
121
+ const name = opts.name ?? cookieNames(opts.secure).session;
122
+ const now = opts.now ?? Date.now;
123
+
124
+ const presentChunks = (request: Request) => {
125
+ const cookies = parseCookies(request.headers.get("cookie"));
126
+ const names: string[] = [];
127
+ for (let i = 0; i < MAX_CHUNKS; i++) if (cookies.has(chunkName(name, i))) names.push(chunkName(name, i));
128
+ return { cookies, names };
129
+ };
130
+
131
+ return {
132
+ async read(request) {
133
+ const { cookies } = presentChunks(request);
134
+ let sealed = "";
135
+ for (let i = 0; i < MAX_CHUNKS; i++) {
136
+ const part = cookies.get(chunkName(name, i));
137
+ if (part === undefined) break;
138
+ sealed += part;
139
+ }
140
+ if (!sealed) return null;
141
+ const opened = await unseal<StoredConnection>(sealed, { secret: opts.secret, purpose: "synap-connect/session", now: now() });
142
+ return opened.ok ? unpack(opened.value) : null;
143
+ },
144
+
145
+ async write(connection, request) {
146
+ const expiresAt = connectionExpiresAt(connection);
147
+ const sealed = await seal(pack(connection), { secret: opts.secret, purpose: "synap-connect/session", expiresAt });
148
+ const parts: string[] = [];
149
+ for (let i = 0; i < sealed.length; i += CHUNK_SIZE) parts.push(sealed.slice(i, i + CHUNK_SIZE));
150
+ if (parts.length > MAX_CHUNKS) {
151
+ throw new ConnectError(
152
+ "misconfigured",
153
+ "The connection is too large for a cookie (a very wide grant); pass a server-side ConnectionStore",
154
+ );
155
+ }
156
+ const maxAgeSec = (expiresAt - now()) / 1000;
157
+ const out = parts.map((value, i) => serializeCookie(chunkName(name, i), value, { maxAgeSec, secure: opts.secure }));
158
+ // Drop chunks a previous, larger value left behind.
159
+ for (const stale of presentChunks(request).names.slice(parts.length)) {
160
+ out.push(serializeCookie(stale, "", { maxAgeSec: 0, secure: opts.secure }));
161
+ }
162
+ return out;
163
+ },
164
+
165
+ async clear(request) {
166
+ const { names } = presentChunks(request);
167
+ return (names.length > 0 ? names : [name]).map((n) => serializeCookie(n, "", { maxAgeSec: 0, secure: opts.secure }));
168
+ },
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Apply Set-Cookie values to a request's Cookie header, so code that runs
174
+ * later IN THE SAME REQUEST (a server component after `proxy.ts`) reads the
175
+ * refreshed value. A `Max-Age=0` removes the cookie.
176
+ */
177
+ export function applySetCookies(cookieHeader: string | null, setCookies: readonly string[]): string {
178
+ const cookies = parseCookies(cookieHeader);
179
+ for (const sc of setCookies) {
180
+ const [pair, ...attrs] = sc.split(";");
181
+ const eq = pair.indexOf("=");
182
+ if (eq < 0) continue;
183
+ const name = pair.slice(0, eq).trim();
184
+ const removed = attrs.some((a) => /^\s*max-age\s*=\s*0\s*$/i.test(a));
185
+ if (removed) cookies.delete(name);
186
+ else cookies.set(name, pair.slice(eq + 1).trim());
187
+ }
188
+ return [...cookies].map(([k, v]) => `${k}=${v}`).join("; ");
189
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Wire and session shapes of pod-app sign-in.
3
+ *
4
+ * DRIFT RISK: hand-written from the pod's OAuth AS (synap-backend
5
+ * `routers/oauth/routes.ts` /token for a CIMD client, `protocol.ts`
6
+ * `buildAuthorizationServerMetadata`, spec 17 §6.6). The contract fixture
7
+ * `src/__tests__/connect.contract.test.ts` copies the token response; when the
8
+ * backend changes, update both.
9
+ */
10
+
11
+ /** One permission the app asks for, with the sentence the consent screen shows. */
12
+ export interface AppRequest {
13
+ /** Grant grammar, e.g. `entity.post.read`, `document.update`. No `*`. */
14
+ permission: string;
15
+ /** Why the app needs it (at most 200 characters). */
16
+ reason: string;
17
+ }
18
+
19
+ /** What the person approved: one row per permission per space. */
20
+ export interface PodGrant {
21
+ permission: string;
22
+ workspaceId: string;
23
+ workspaceName: string;
24
+ }
25
+
26
+ export interface PodUser {
27
+ id: string;
28
+ name: string;
29
+ }
30
+
31
+ /** A pod's authorization server, as `discoverPod` validated it. */
32
+ export interface PodIssuer {
33
+ /** The canonical issuer, which is also the pod's base URL. */
34
+ issuer: string;
35
+ authorizationEndpoint: string;
36
+ tokenEndpoint: string;
37
+ revocationEndpoint: string | null;
38
+ /** The AS advertises `grant_types_supported: [..., "refresh_token"]`. */
39
+ refreshSupported: boolean;
40
+ }
41
+
42
+ /**
43
+ * A live connection of this app to one person's pod. Server-only: it holds the
44
+ * bearer token. Serialisable, so a custom store can keep it anywhere.
45
+ */
46
+ export interface PodConnection {
47
+ /** The pod's canonical issuer = its base URL (no trailing slash). */
48
+ podUrl: string;
49
+ /** The app's client_id (its CIMD URL) the tokens were issued to. */
50
+ clientId: string;
51
+ tokenEndpoint: string;
52
+ revocationEndpoint: string | null;
53
+ accessToken: string;
54
+ /** ms since epoch. */
55
+ accessExpiresAt: number;
56
+ /** Absent when the pod issued none (then the connection ends at `accessExpiresAt`). */
57
+ refreshToken?: string;
58
+ /** ms since epoch; the refresh token's idle life, renewed by each rotation. */
59
+ refreshExpiresAt?: number;
60
+ /** The approved permissions (the token response's `scope`). */
61
+ scope: string[];
62
+ grant: PodGrant[];
63
+ user: PodUser;
64
+ /** ms since epoch. */
65
+ connectedAt: number;
66
+ /**
67
+ * The pod's id for this app connection (`app_…`, the grant's client id), when
68
+ * the token response names it (`synap_app.id`). Absent from pods that do not.
69
+ */
70
+ appId?: string;
71
+ /**
72
+ * The pod's own page for this app connection (pod-admin `/apps/<appId>`:
73
+ * what it may touch, narrow it, apply automatically, revoke), when the token
74
+ * response names it (`synap_app.manageUrl`). Absent otherwise: hide the link,
75
+ * never guess one.
76
+ */
77
+ manageUrl?: string;
78
+ }
79
+
80
+ /** One space of the grant, its permissions together (`spaces(connection)`). */
81
+ export interface PodSpace {
82
+ workspaceId: string;
83
+ /** "" when the pod sent no name. */
84
+ workspaceName: string;
85
+ permissions: string[];
86
+ }
87
+
88
+ /** The CIMD document an app serves at its client_id URL (spec 17 §6.1). */
89
+ export interface ClientMetadataDocument {
90
+ client_id: string;
91
+ client_name: string;
92
+ client_uri: string;
93
+ redirect_uris: string[];
94
+ grant_types: string[];
95
+ response_types: string[];
96
+ token_endpoint_auth_method: "none";
97
+ initiate_login_uri: string;
98
+ synap_requests: AppRequest[];
99
+ synap_templates?: string[];
100
+ }