@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,151 @@
|
|
|
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 { parseCookies, serializeCookie } from "./encoding";
|
|
11
|
+
import { ConnectError } from "./errors";
|
|
12
|
+
import { seal, unseal, type AppSecret } from "./seal";
|
|
13
|
+
import type { PodConnection, PodGrant } from "./types";
|
|
14
|
+
|
|
15
|
+
export interface ConnectionStore {
|
|
16
|
+
/** The request's connection, or null (absent, tampered, foreign, expired). */
|
|
17
|
+
read(request: Request): Promise<PodConnection | null>;
|
|
18
|
+
/** Persist; returns the Set-Cookie header values to send. */
|
|
19
|
+
write(connection: PodConnection, request: Request): Promise<string[]>;
|
|
20
|
+
/** Forget; returns the Set-Cookie header values to send. */
|
|
21
|
+
clear(request: Request): Promise<string[]>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CookieNames {
|
|
25
|
+
session: string;
|
|
26
|
+
transaction: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `__Host-` names on https (Secure, Path=/, no Domain: bound to this exact host). */
|
|
30
|
+
export function cookieNames(secure: boolean): CookieNames {
|
|
31
|
+
return secure
|
|
32
|
+
? { session: "__Host-synap_session", transaction: "__Host-synap_tx" }
|
|
33
|
+
: { session: "synap_session", transaction: "synap_tx" };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Browsers cap a cookie near 4096 bytes; a big grant spans several. */
|
|
37
|
+
const CHUNK_SIZE = 3800;
|
|
38
|
+
const MAX_CHUNKS = 4;
|
|
39
|
+
|
|
40
|
+
function chunkName(name: string, i: number): string {
|
|
41
|
+
return i === 0 ? name : `${name}.${i}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The sealed form: the grant grouped per space (it repeats the space otherwise). */
|
|
45
|
+
interface StoredConnection extends Omit<PodConnection, "grant"> {
|
|
46
|
+
g: Array<[workspaceId: string, workspaceName: string, permissions: string[]]>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pack(c: PodConnection): StoredConnection {
|
|
50
|
+
const bySpace = new Map<string, [string, string, string[]]>();
|
|
51
|
+
for (const g of c.grant) {
|
|
52
|
+
const row = bySpace.get(g.workspaceId) ?? [g.workspaceId, g.workspaceName, []];
|
|
53
|
+
row[2].push(g.permission);
|
|
54
|
+
bySpace.set(g.workspaceId, row);
|
|
55
|
+
}
|
|
56
|
+
const { grant: _grant, ...rest } = c;
|
|
57
|
+
return { ...rest, g: [...bySpace.values()] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function unpack(s: StoredConnection): PodConnection | null {
|
|
61
|
+
if (!s || typeof s.podUrl !== "string" || typeof s.accessToken !== "string" || !Array.isArray(s.g)) return null;
|
|
62
|
+
const { g, ...rest } = s;
|
|
63
|
+
const grant: PodGrant[] = g.flatMap(([workspaceId, workspaceName, permissions]) =>
|
|
64
|
+
permissions.map((permission) => ({ permission, workspaceId, workspaceName })),
|
|
65
|
+
);
|
|
66
|
+
return { ...rest, grant };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** When the stored value stops being useful. */
|
|
70
|
+
export function connectionExpiresAt(c: PodConnection): number {
|
|
71
|
+
return c.refreshToken && c.refreshExpiresAt !== undefined ? Math.max(c.refreshExpiresAt, c.accessExpiresAt) : c.accessExpiresAt;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface SealedCookieStoreOptions {
|
|
75
|
+
secret: AppSecret;
|
|
76
|
+
/** True on https (the default when the app URL is https). */
|
|
77
|
+
secure: boolean;
|
|
78
|
+
name?: string;
|
|
79
|
+
now?: () => number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createSealedCookieStore(opts: SealedCookieStoreOptions): ConnectionStore {
|
|
83
|
+
const name = opts.name ?? cookieNames(opts.secure).session;
|
|
84
|
+
const now = opts.now ?? Date.now;
|
|
85
|
+
|
|
86
|
+
const presentChunks = (request: Request) => {
|
|
87
|
+
const cookies = parseCookies(request.headers.get("cookie"));
|
|
88
|
+
const names: string[] = [];
|
|
89
|
+
for (let i = 0; i < MAX_CHUNKS; i++) if (cookies.has(chunkName(name, i))) names.push(chunkName(name, i));
|
|
90
|
+
return { cookies, names };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
async read(request) {
|
|
95
|
+
const { cookies } = presentChunks(request);
|
|
96
|
+
let sealed = "";
|
|
97
|
+
for (let i = 0; i < MAX_CHUNKS; i++) {
|
|
98
|
+
const part = cookies.get(chunkName(name, i));
|
|
99
|
+
if (part === undefined) break;
|
|
100
|
+
sealed += part;
|
|
101
|
+
}
|
|
102
|
+
if (!sealed) return null;
|
|
103
|
+
const opened = await unseal<StoredConnection>(sealed, { secret: opts.secret, purpose: "synap-connect/session", now: now() });
|
|
104
|
+
return opened.ok ? unpack(opened.value) : null;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async write(connection, request) {
|
|
108
|
+
const expiresAt = connectionExpiresAt(connection);
|
|
109
|
+
const sealed = await seal(pack(connection), { secret: opts.secret, purpose: "synap-connect/session", expiresAt });
|
|
110
|
+
const parts: string[] = [];
|
|
111
|
+
for (let i = 0; i < sealed.length; i += CHUNK_SIZE) parts.push(sealed.slice(i, i + CHUNK_SIZE));
|
|
112
|
+
if (parts.length > MAX_CHUNKS) {
|
|
113
|
+
throw new ConnectError(
|
|
114
|
+
"misconfigured",
|
|
115
|
+
"The connection is too large for a cookie (a very wide grant); pass a server-side ConnectionStore",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const maxAgeSec = (expiresAt - now()) / 1000;
|
|
119
|
+
const out = parts.map((value, i) => serializeCookie(chunkName(name, i), value, { maxAgeSec, secure: opts.secure }));
|
|
120
|
+
// Drop chunks a previous, larger value left behind.
|
|
121
|
+
for (const stale of presentChunks(request).names.slice(parts.length)) {
|
|
122
|
+
out.push(serializeCookie(stale, "", { maxAgeSec: 0, secure: opts.secure }));
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async clear(request) {
|
|
128
|
+
const { names } = presentChunks(request);
|
|
129
|
+
return (names.length > 0 ? names : [name]).map((n) => serializeCookie(n, "", { maxAgeSec: 0, secure: opts.secure }));
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Apply Set-Cookie values to a request's Cookie header, so code that runs
|
|
136
|
+
* later IN THE SAME REQUEST (a server component after `proxy.ts`) reads the
|
|
137
|
+
* refreshed value. A `Max-Age=0` removes the cookie.
|
|
138
|
+
*/
|
|
139
|
+
export function applySetCookies(cookieHeader: string | null, setCookies: readonly string[]): string {
|
|
140
|
+
const cookies = parseCookies(cookieHeader);
|
|
141
|
+
for (const sc of setCookies) {
|
|
142
|
+
const [pair, ...attrs] = sc.split(";");
|
|
143
|
+
const eq = pair.indexOf("=");
|
|
144
|
+
if (eq < 0) continue;
|
|
145
|
+
const name = pair.slice(0, eq).trim();
|
|
146
|
+
const removed = attrs.some((a) => /^\s*max-age\s*=\s*0\s*$/i.test(a));
|
|
147
|
+
if (removed) cookies.delete(name);
|
|
148
|
+
else cookies.set(name, pair.slice(eq + 1).trim());
|
|
149
|
+
}
|
|
150
|
+
return [...cookies].map(([k, v]) => `${k}=${v}`).join("; ");
|
|
151
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
|
|
68
|
+
/** The CIMD document an app serves at its client_id URL (spec 17 §6.1). */
|
|
69
|
+
export interface ClientMetadataDocument {
|
|
70
|
+
client_id: string;
|
|
71
|
+
client_name: string;
|
|
72
|
+
client_uri: string;
|
|
73
|
+
redirect_uris: string[];
|
|
74
|
+
grant_types: string[];
|
|
75
|
+
response_types: string[];
|
|
76
|
+
token_endpoint_auth_method: "none";
|
|
77
|
+
initiate_login_uri: string;
|
|
78
|
+
synap_requests: AppRequest[];
|
|
79
|
+
synap_templates?: string[];
|
|
80
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
1
|
export { createSynapClient } from "./client";
|
|
2
2
|
export type { SynapClient, SynapClientOptions, AppRouter } from "./client";
|
|
3
|
+
export {
|
|
4
|
+
ensureTemplate,
|
|
5
|
+
ensuredWorkspaceId,
|
|
6
|
+
parseEnsureTemplateResult,
|
|
7
|
+
} from "./templates";
|
|
8
|
+
export type {
|
|
9
|
+
EnsureTemplateClient,
|
|
10
|
+
EnsureTemplateInput,
|
|
11
|
+
EnsureTemplateMissingTemplate,
|
|
12
|
+
EnsureTemplateOptions,
|
|
13
|
+
EnsureTemplateResult,
|
|
14
|
+
EnsureTemplateStatus,
|
|
15
|
+
} from "./templates";
|