@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
package/src/templates.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ensureTemplate — an app asks the pod whether the template it runs on is
|
|
3
|
+
* installed, and (optionally) asks for it to be installed.
|
|
4
|
+
*
|
|
5
|
+
* TEMPLATE-LIFECYCLE Contract B. ONE door: tRPC `workspaces.ensureTemplate`
|
|
6
|
+
* (Hub REST mirror `POST /api/hub/workspaces/ensure-template`). The pod
|
|
7
|
+
* resolves the definition from the catalog and installs through its governed
|
|
8
|
+
* install door — an app NEVER sends a definition. "Installed" means an ACTIVE
|
|
9
|
+
* workspace whose `settings.packageSlug` is the slug.
|
|
10
|
+
*
|
|
11
|
+
* The result union below is HAND-WRITTEN from the contract
|
|
12
|
+
* (`TEMPLATE-LIFECYCLE/DESIGN.md`, Contract B) because the published
|
|
13
|
+
* `@synap-core/api-types` does not carry the procedure until the next regen.
|
|
14
|
+
* Once it does, `EnsureTemplateResult` should become
|
|
15
|
+
* `RouterOutputs["workspaces"]["ensureTemplate"]`.
|
|
16
|
+
*
|
|
17
|
+
* A failed call REJECTS. It is never folded into `missing`: "the pod said the
|
|
18
|
+
* template is not installed" and "we could not ask the pod" are different
|
|
19
|
+
* facts, and only the first may offer an install.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface EnsureTemplateInput {
|
|
23
|
+
slug: string;
|
|
24
|
+
/** Ask the pod to install it when missing. */
|
|
25
|
+
install?: boolean;
|
|
26
|
+
/** One plain sentence: why the app needs it (shown on the install request). */
|
|
27
|
+
reason?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EnsureTemplateMissingTemplate {
|
|
31
|
+
slug: string;
|
|
32
|
+
name: string;
|
|
33
|
+
description: string | null;
|
|
34
|
+
icon: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type EnsureTemplateResult =
|
|
38
|
+
/** Installed: an ACTIVE workspace carries the template. */
|
|
39
|
+
| { status: "installed"; workspaceId: string; version: string | null; drifted: boolean }
|
|
40
|
+
/** Not installed, and no install was asked. */
|
|
41
|
+
| { status: "missing"; template: EnsureTemplateMissingTemplate }
|
|
42
|
+
/** Install asked by an agent or app key: the governed install filed a proposal. */
|
|
43
|
+
| { status: "proposed"; proposalId: string; reviewUrl: string }
|
|
44
|
+
/** Install asked by a person: it is being installed now. */
|
|
45
|
+
| { status: "installing"; workspaceId: string }
|
|
46
|
+
/**
|
|
47
|
+
* Another live space already holds this template's domain (one space per
|
|
48
|
+
* domain), so the pod will not install a twin. The person decides what to
|
|
49
|
+
* do with that space; `guidance` is the pod's own sentence.
|
|
50
|
+
*/
|
|
51
|
+
| { status: "exists"; workspaceId: string; workspaceName: string; guidance: string }
|
|
52
|
+
/** The catalog retired the template; `replacedBy` is its successor, if any. */
|
|
53
|
+
| { status: "retired"; replacedBy: string | null }
|
|
54
|
+
/** A COMPLETE catalog sync does not list the slug. */
|
|
55
|
+
| { status: "not_in_catalog" }
|
|
56
|
+
/** The pod cannot tell yet (catalog cache cold or last sync incomplete). */
|
|
57
|
+
| { status: "unknown" };
|
|
58
|
+
|
|
59
|
+
export type EnsureTemplateStatus = EnsureTemplateResult["status"];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The slice of the tRPC client this helper needs. Structural on purpose: it
|
|
63
|
+
* accepts `createSynapClient(...)` and a React `trpc.useUtils().client` alike,
|
|
64
|
+
* and keeps compiling before the procedure lands in the generated types.
|
|
65
|
+
*/
|
|
66
|
+
export interface EnsureTemplateClient {
|
|
67
|
+
workspaces: {
|
|
68
|
+
ensureTemplate: {
|
|
69
|
+
mutate(input: EnsureTemplateInput): Promise<unknown>;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface EnsureTemplateOptions {
|
|
75
|
+
install?: boolean;
|
|
76
|
+
reason?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const STATUSES: ReadonlySet<string> = new Set<EnsureTemplateStatus>([
|
|
80
|
+
"installed",
|
|
81
|
+
"missing",
|
|
82
|
+
"proposed",
|
|
83
|
+
"installing",
|
|
84
|
+
"exists",
|
|
85
|
+
"retired",
|
|
86
|
+
"not_in_catalog",
|
|
87
|
+
"unknown",
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Narrow the wire answer. An answer whose `status` is not one the contract
|
|
92
|
+
* names is a protocol error and throws: guessing "missing" would offer an
|
|
93
|
+
* install the pod never said was needed.
|
|
94
|
+
*/
|
|
95
|
+
export function parseEnsureTemplateResult(raw: unknown): EnsureTemplateResult {
|
|
96
|
+
const status =
|
|
97
|
+
raw && typeof raw === "object" ? (raw as { status?: unknown }).status : undefined;
|
|
98
|
+
if (typeof status !== "string" || !STATUSES.has(status)) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`ensureTemplate: the pod answered with an unknown status (${String(status)}).`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return raw as EnsureTemplateResult;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function ensureTemplate(
|
|
107
|
+
client: EnsureTemplateClient,
|
|
108
|
+
slug: string,
|
|
109
|
+
opts: EnsureTemplateOptions = {},
|
|
110
|
+
): Promise<EnsureTemplateResult> {
|
|
111
|
+
const input: EnsureTemplateInput = { slug };
|
|
112
|
+
if (opts.install) input.install = true;
|
|
113
|
+
if (opts.reason) input.reason = opts.reason;
|
|
114
|
+
return parseEnsureTemplateResult(await client.workspaces.ensureTemplate.mutate(input));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The workspace the template lives in, when the answer names one. */
|
|
118
|
+
export function ensuredWorkspaceId(result: EnsureTemplateResult): string | null {
|
|
119
|
+
return result.status === "installed" || result.status === "installing"
|
|
120
|
+
? result.workspaceId
|
|
121
|
+
: null;
|
|
122
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A mock pod for pod-app sign-in: its OAuth authorization server (RFC 8414
|
|
3
|
+
* metadata, /authorize as a function standing in for the consent screen,
|
|
4
|
+
* /token, /revoke) and a few Hub REST doors behind the Bearer token. Inject
|
|
5
|
+
* `fetch` into the REAL `@synap-core/sdk/connect` (`createPodAppAuth({ fetch })`)
|
|
6
|
+
* so everything above the wire is the production path. No network.
|
|
7
|
+
*
|
|
8
|
+
* Wire behaviour mirrors synap-backend `routers/oauth/routes.ts` for a CIMD
|
|
9
|
+
* client: `iss` on every authorization answer (RFC 9207), PKCE S256 checked at
|
|
10
|
+
* /token, single-use codes bound to client_id + redirect_uri + resource, the
|
|
11
|
+
* token response's `synap_grant` / `synap_user`. Refresh mirrors PB3
|
|
12
|
+
* (`routers/oauth/refresh.ts`): rotating and single-use, 30 days from issue;
|
|
13
|
+
* a token presented AGAIN after its use is a reuse that revokes its whole
|
|
14
|
+
* family AND the live access key; a new code ends the client's older
|
|
15
|
+
* families; /revoke with a refresh token kills its family and access key.
|
|
16
|
+
* `refresh: false` is a pod before PB3 (no refresh_token at all).
|
|
17
|
+
*
|
|
18
|
+
* mock.authorize(authorizeUrl) → the callback URL (approved)
|
|
19
|
+
* mock.authorize(authorizeUrl, { deny: true }) → error=access_denied
|
|
20
|
+
* mock.authorize(authorizeUrl, { iss: "https://evil.example" }) → a mix-up attempt
|
|
21
|
+
* mock.loseNextRefreshRace() → the next redeem is a concurrent loser (refused, no revoke)
|
|
22
|
+
* mock.consumeRefreshToken(t) → the token was used elsewhere (presenting it is a reuse)
|
|
23
|
+
* mock.revokeAll() → the person removed the app in their pod
|
|
24
|
+
*/
|
|
25
|
+
import { pkceChallenge, randomToken } from "../connect/encoding";
|
|
26
|
+
import type { PodGrant, PodUser } from "../connect/types";
|
|
27
|
+
|
|
28
|
+
/** A placeholder pod origin; never contacted. */
|
|
29
|
+
export const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
|
|
30
|
+
|
|
31
|
+
export const MOCK_GRANT: PodGrant[] = [
|
|
32
|
+
{ permission: "entity.post.read", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
|
|
33
|
+
{ permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export const MOCK_USER: PodUser = { id: "user_mock_ada", name: "Ada Lovelace" };
|
|
37
|
+
|
|
38
|
+
export type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported" | "unreachable" | "server" | "unavailable";
|
|
39
|
+
|
|
40
|
+
export interface MockAuthServerOptions {
|
|
41
|
+
issuer?: string;
|
|
42
|
+
/** Issue rotating refresh tokens (PB3). Default true; false = a pod before PB3. */
|
|
43
|
+
refresh?: boolean;
|
|
44
|
+
/** Default 3600 (the pod's 1 h). */
|
|
45
|
+
accessTtlSec?: number;
|
|
46
|
+
/** Default 30 days. */
|
|
47
|
+
refreshIdleSec?: number;
|
|
48
|
+
/** Send `refresh_token_expires_in` with each refresh token. Default false (the pod does not say). */
|
|
49
|
+
advertiseRefreshExpiry?: boolean;
|
|
50
|
+
grant?: PodGrant[];
|
|
51
|
+
user?: PodUser;
|
|
52
|
+
/** Override or remove (undefined) metadata fields: a pod too old, a lying issuer. */
|
|
53
|
+
metadata?: Record<string, unknown>;
|
|
54
|
+
/** Hub writes: "proposed" (ask-first, the default for an app) or "applied". */
|
|
55
|
+
writeMode?: "proposed" | "applied";
|
|
56
|
+
/** Hub paths (after /api/hub) that answer one failure. */
|
|
57
|
+
failures?: Record<string, MockHubFailure>;
|
|
58
|
+
now?: () => number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MockAuthRequest {
|
|
62
|
+
method: string;
|
|
63
|
+
url: string;
|
|
64
|
+
/** Lower-cased header names. */
|
|
65
|
+
headers: Record<string, string>;
|
|
66
|
+
/** The form body of a /token or /revoke call. */
|
|
67
|
+
form: Record<string, string> | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AuthorizeDecision {
|
|
71
|
+
/** The person said no. */
|
|
72
|
+
deny?: boolean;
|
|
73
|
+
/** Override `iss` on the answer (null = omit it): a mix-up attack. */
|
|
74
|
+
iss?: string | null;
|
|
75
|
+
/** Override `state` on the answer: a forged answer. */
|
|
76
|
+
state?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MockAuthServer {
|
|
80
|
+
issuer: string;
|
|
81
|
+
fetch: typeof fetch;
|
|
82
|
+
requests: MockAuthRequest[];
|
|
83
|
+
/** Hub writes seen, in order. */
|
|
84
|
+
writes: Array<{ method: string; path: string; body: unknown }>;
|
|
85
|
+
/** Stand-in for the consent screen: validates the request like /authorize, returns where the pod redirects. */
|
|
86
|
+
authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
|
|
87
|
+
/** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
|
|
88
|
+
consumeRefreshToken(token: string): void;
|
|
89
|
+
/** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
|
|
90
|
+
loseNextRefreshRace(): void;
|
|
91
|
+
/** Refresh-token redeems that were reuses (each revoked a family). */
|
|
92
|
+
reuses: number;
|
|
93
|
+
/** The person revoked the app: every token stops working. */
|
|
94
|
+
revokeAll(): void;
|
|
95
|
+
/** Live (unrevoked) refresh tokens. */
|
|
96
|
+
liveRefreshTokens(): string[];
|
|
97
|
+
reset(): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface CodeRow {
|
|
101
|
+
clientId: string;
|
|
102
|
+
redirectUri: string;
|
|
103
|
+
challenge: string;
|
|
104
|
+
resource: string | null;
|
|
105
|
+
scope: string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface AccessRow {
|
|
109
|
+
expiresAt: number;
|
|
110
|
+
revoked: boolean;
|
|
111
|
+
family: string | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface RefreshRow {
|
|
115
|
+
clientId: string;
|
|
116
|
+
family: string;
|
|
117
|
+
idleUntil: number;
|
|
118
|
+
used: boolean;
|
|
119
|
+
revoked: boolean;
|
|
120
|
+
scope: string[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function json(status: number, body: unknown, headers: Record<string, string> = {}): Response {
|
|
124
|
+
return new Response(JSON.stringify(body), {
|
|
125
|
+
status,
|
|
126
|
+
headers: { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function oauthError(error: string, description: string, status = 400): Response {
|
|
131
|
+
return json(status, { error, error_description: description });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createMockAuthServer(options: MockAuthServerOptions = {}): MockAuthServer {
|
|
135
|
+
const issuer = options.issuer ?? MOCK_AUTH_ISSUER;
|
|
136
|
+
const now = options.now ?? Date.now;
|
|
137
|
+
const refresh = options.refresh ?? true;
|
|
138
|
+
const accessTtl = options.accessTtlSec ?? 3600;
|
|
139
|
+
const refreshIdle = options.refreshIdleSec ?? 30 * 24 * 3600;
|
|
140
|
+
const grant = options.grant ?? MOCK_GRANT;
|
|
141
|
+
const user = options.user ?? MOCK_USER;
|
|
142
|
+
const codes = new Map<string, CodeRow>();
|
|
143
|
+
const access = new Map<string, AccessRow>();
|
|
144
|
+
const refreshTokens = new Map<string, RefreshRow>();
|
|
145
|
+
const requests: MockAuthRequest[] = [];
|
|
146
|
+
const writes: Array<{ method: string; path: string; body: unknown }> = [];
|
|
147
|
+
let proposalSeq = 0;
|
|
148
|
+
|
|
149
|
+
const metadata = (): Record<string, unknown> => {
|
|
150
|
+
const base: Record<string, unknown> = {
|
|
151
|
+
issuer,
|
|
152
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
153
|
+
token_endpoint: `${issuer}/token`,
|
|
154
|
+
registration_endpoint: `${issuer}/register`,
|
|
155
|
+
revocation_endpoint: `${issuer}/revoke`,
|
|
156
|
+
revocation_endpoint_auth_methods_supported: ["none"],
|
|
157
|
+
response_types_supported: ["code"],
|
|
158
|
+
grant_types_supported: refresh ? ["authorization_code", "refresh_token"] : ["authorization_code"],
|
|
159
|
+
code_challenge_methods_supported: ["S256"],
|
|
160
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
161
|
+
authorization_response_iss_parameter_supported: true,
|
|
162
|
+
client_id_metadata_document_supported: true,
|
|
163
|
+
};
|
|
164
|
+
for (const [k, v] of Object.entries(options.metadata ?? {})) {
|
|
165
|
+
if (v === undefined) delete base[k];
|
|
166
|
+
else base[k] = v;
|
|
167
|
+
}
|
|
168
|
+
return base;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
let loseNextRace = false;
|
|
172
|
+
let reuses = 0;
|
|
173
|
+
const revokeFamily = (family: string) => {
|
|
174
|
+
for (const r of refreshTokens.values()) if (r.family === family) r.revoked = true;
|
|
175
|
+
for (const a of access.values()) if (a.family === family) a.revoked = true;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const tokenBody = (scope: string[], clientId: string, family: string) => {
|
|
179
|
+
const accessToken = `synap_hub_mock_${randomToken()}`;
|
|
180
|
+
access.set(accessToken, { expiresAt: now() + accessTtl * 1000, revoked: false, family: refresh ? family : null });
|
|
181
|
+
const body: Record<string, unknown> = {
|
|
182
|
+
access_token: accessToken,
|
|
183
|
+
token_type: "Bearer",
|
|
184
|
+
expires_in: accessTtl,
|
|
185
|
+
scope: scope.join(" "),
|
|
186
|
+
synap_grant: grant.filter((g) => scope.includes(g.permission)),
|
|
187
|
+
synap_user: user,
|
|
188
|
+
};
|
|
189
|
+
if (refresh) {
|
|
190
|
+
const refreshToken = randomToken();
|
|
191
|
+
refreshTokens.set(refreshToken, { clientId, family, idleUntil: now() + refreshIdle * 1000, used: false, revoked: false, scope });
|
|
192
|
+
body.refresh_token = refreshToken;
|
|
193
|
+
if (options.advertiseRefreshExpiry) body.refresh_token_expires_in = refreshIdle;
|
|
194
|
+
}
|
|
195
|
+
return body;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
async function token(form: Record<string, string>): Promise<Response> {
|
|
199
|
+
if (form.grant_type === "authorization_code") {
|
|
200
|
+
const row = form.code ? codes.get(form.code) : undefined;
|
|
201
|
+
if (form.code) codes.delete(form.code); // claim first: a failed exchange burns the code
|
|
202
|
+
if (!row) return oauthError("invalid_grant", "Invalid, expired, or already-redeemed authorization code");
|
|
203
|
+
if (row.clientId !== form.client_id) return oauthError("invalid_grant", "client_id does not match the code");
|
|
204
|
+
if (row.redirectUri !== form.redirect_uri) return oauthError("invalid_grant", "redirect_uri mismatch");
|
|
205
|
+
if (!form.code_verifier || (await pkceChallenge(form.code_verifier)) !== row.challenge) {
|
|
206
|
+
return oauthError("invalid_grant", "PKCE verification failed");
|
|
207
|
+
}
|
|
208
|
+
if (form.resource !== undefined && row.resource && form.resource !== row.resource) {
|
|
209
|
+
return oauthError("invalid_target", "resource does not match the authorization request");
|
|
210
|
+
}
|
|
211
|
+
// The latest connect wins: a new code ends this client's older families.
|
|
212
|
+
for (const r of refreshTokens.values()) if (r.clientId === row.clientId) revokeFamily(r.family);
|
|
213
|
+
return json(200, tokenBody(row.scope, row.clientId, randomToken()));
|
|
214
|
+
}
|
|
215
|
+
if (form.grant_type === "refresh_token") {
|
|
216
|
+
if (!refresh) return oauthError("unsupported_grant_type", 'Only grant_type "authorization_code" is supported');
|
|
217
|
+
if (!form.refresh_token || !form.client_id) return oauthError("invalid_request", "refresh_token and client_id are required");
|
|
218
|
+
const row = refreshTokens.get(form.refresh_token);
|
|
219
|
+
if (!row || row.clientId !== form.client_id || row.revoked) {
|
|
220
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
221
|
+
}
|
|
222
|
+
if (row.used) {
|
|
223
|
+
reuses++;
|
|
224
|
+
revokeFamily(row.family);
|
|
225
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
226
|
+
}
|
|
227
|
+
if (row.idleUntil <= now()) return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
228
|
+
if (loseNextRace) {
|
|
229
|
+
loseNextRace = false;
|
|
230
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
231
|
+
}
|
|
232
|
+
row.used = true;
|
|
233
|
+
return json(200, tokenBody(row.scope, row.clientId, row.family));
|
|
234
|
+
}
|
|
235
|
+
return oauthError("unsupported_grant_type", "Unsupported grant_type");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function hub(method: string, path: string, headers: Record<string, string>, body: unknown): Response | "reject" {
|
|
239
|
+
const failure = options.failures?.[path];
|
|
240
|
+
if (failure === "unreachable") return "reject";
|
|
241
|
+
const bearer = /^Bearer (.+)$/.exec(headers.authorization ?? "")?.[1];
|
|
242
|
+
const row = bearer ? access.get(bearer) : undefined;
|
|
243
|
+
if (!row || row.revoked || row.expiresAt <= now()) return json(401, { error: "Unauthorized" });
|
|
244
|
+
switch (failure) {
|
|
245
|
+
case "rate_limited":
|
|
246
|
+
return json(429, { error: "Too many requests" }, { "Retry-After": "30" });
|
|
247
|
+
case "forbidden":
|
|
248
|
+
return json(403, { error: "Forbidden" });
|
|
249
|
+
case "door_unsupported":
|
|
250
|
+
return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
|
|
251
|
+
case "server":
|
|
252
|
+
return json(500, { error: "Internal error" });
|
|
253
|
+
case "unavailable":
|
|
254
|
+
return json(503, { error: "Unavailable" });
|
|
255
|
+
}
|
|
256
|
+
// The grant fence (spec 17 §5): aggregate reads are refused to a scoped token.
|
|
257
|
+
if (path.startsWith("/knowledge/") || path === "/entities/retrieve" || path === "/memory/search") {
|
|
258
|
+
return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
|
|
259
|
+
}
|
|
260
|
+
if (method === "GET" && path === "/users/me") return json(200, user);
|
|
261
|
+
if (method === "GET" && path === "/entities") return json(200, { entities: [], total: 0 });
|
|
262
|
+
if (method !== "GET") {
|
|
263
|
+
writes.push({ method, path, body });
|
|
264
|
+
if ((options.writeMode ?? "proposed") === "proposed") {
|
|
265
|
+
const proposalId = `prop_mock_${++proposalSeq}`;
|
|
266
|
+
return json(200, {
|
|
267
|
+
status: "proposed",
|
|
268
|
+
message: "Sent for review",
|
|
269
|
+
proposalId,
|
|
270
|
+
reviewUrl: `${issuer}/open/${proposalId}`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return json(200, { status: "created", id: `ent_mock_${writes.length}` });
|
|
274
|
+
}
|
|
275
|
+
return json(404, { error: "Not found" });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const mockFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
279
|
+
const request = typeof input === "object" && "method" in input ? input : null;
|
|
280
|
+
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
|
281
|
+
const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
|
|
282
|
+
const headers: Record<string, string> = {};
|
|
283
|
+
new Headers(init?.headers ?? request?.headers).forEach((value, name) => {
|
|
284
|
+
headers[name.toLowerCase()] = value;
|
|
285
|
+
});
|
|
286
|
+
const rawBody = typeof init?.body === "string" ? init.body : request ? await request.clone().text() : "";
|
|
287
|
+
const isForm = (headers["content-type"] ?? "").includes("application/x-www-form-urlencoded");
|
|
288
|
+
const form = isForm ? Object.fromEntries(new URLSearchParams(rawBody)) : null;
|
|
289
|
+
requests.push({ method, url: url.href, headers, form });
|
|
290
|
+
|
|
291
|
+
if (url.origin !== new URL(issuer).origin) throw new TypeError(`Failed to fetch: ${url.origin} is not the mock pod`);
|
|
292
|
+
const path = url.pathname;
|
|
293
|
+
if (method === "GET" && path === "/.well-known/oauth-authorization-server") return json(200, metadata());
|
|
294
|
+
if (method === "POST" && path === "/token") return token(form ?? {});
|
|
295
|
+
if (method === "POST" && path === "/revoke") {
|
|
296
|
+
const t = form?.token ?? "";
|
|
297
|
+
const r = refreshTokens.get(t);
|
|
298
|
+
if (r && form?.client_id && form.client_id !== r.clientId) {
|
|
299
|
+
return oauthError("unauthorized_client", "The token was not issued to this client");
|
|
300
|
+
}
|
|
301
|
+
if (r) revokeFamily(r.family);
|
|
302
|
+
const a = access.get(t);
|
|
303
|
+
if (a) a.revoked = true;
|
|
304
|
+
return new Response(null, { status: 200 });
|
|
305
|
+
}
|
|
306
|
+
const hubPath = /^\/api\/hub(\/.*)$/.exec(path)?.[1];
|
|
307
|
+
if (hubPath) {
|
|
308
|
+
let body: unknown = undefined;
|
|
309
|
+
try {
|
|
310
|
+
body = rawBody ? JSON.parse(rawBody) : undefined;
|
|
311
|
+
} catch {
|
|
312
|
+
body = rawBody;
|
|
313
|
+
}
|
|
314
|
+
const res = hub(method, hubPath, headers, body);
|
|
315
|
+
if (res === "reject") throw new TypeError("Failed to fetch: mock pod unreachable");
|
|
316
|
+
return res;
|
|
317
|
+
}
|
|
318
|
+
return json(404, { error: "Not found" });
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
issuer,
|
|
323
|
+
fetch: mockFetch as typeof fetch,
|
|
324
|
+
requests,
|
|
325
|
+
writes,
|
|
326
|
+
authorize(authorizeUrl, decision = {}) {
|
|
327
|
+
const url = new URL(authorizeUrl);
|
|
328
|
+
if (`${url.origin}${url.pathname}` !== `${issuer}/authorize`) throw new Error(`Not this pod's /authorize: ${authorizeUrl}`);
|
|
329
|
+
const q = url.searchParams;
|
|
330
|
+
const redirectUri = q.get("redirect_uri");
|
|
331
|
+
const clientId = q.get("client_id");
|
|
332
|
+
// Phase 1 (shown, never redirected): no verified redirect yet.
|
|
333
|
+
if (!clientId || !redirectUri) throw new Error("Authorization request rejected: client_id and redirect_uri are required");
|
|
334
|
+
const answer = new URL(redirectUri);
|
|
335
|
+
const state = decision.state ?? q.get("state");
|
|
336
|
+
const finish = (fields: Record<string, string>) => {
|
|
337
|
+
for (const [k, v] of Object.entries(fields)) answer.searchParams.set(k, v);
|
|
338
|
+
if (state !== null) answer.searchParams.set("state", state);
|
|
339
|
+
const iss = decision.iss === undefined ? issuer : decision.iss;
|
|
340
|
+
if (iss !== null) answer.searchParams.set("iss", iss);
|
|
341
|
+
return answer.href;
|
|
342
|
+
};
|
|
343
|
+
if (q.get("response_type") !== "code") return finish({ error: "unsupported_response_type" });
|
|
344
|
+
if (q.get("code_challenge_method") !== "S256" || !q.get("code_challenge")) {
|
|
345
|
+
return finish({ error: "invalid_request", error_description: "PKCE S256 is required" });
|
|
346
|
+
}
|
|
347
|
+
const resource = q.get("resource");
|
|
348
|
+
if (resource !== null && resource !== `${issuer}/api/hub` && resource !== `${issuer}/mcp`) {
|
|
349
|
+
return finish({ error: "invalid_target" });
|
|
350
|
+
}
|
|
351
|
+
const scope = (q.get("scope") ?? "").split(/\s+/).filter(Boolean);
|
|
352
|
+
if (scope.length === 0) return finish({ error: "invalid_scope" });
|
|
353
|
+
if (decision.deny) return finish({ error: "access_denied" });
|
|
354
|
+
const code = randomToken();
|
|
355
|
+
codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge")!, resource, scope });
|
|
356
|
+
return finish({ code });
|
|
357
|
+
},
|
|
358
|
+
consumeRefreshToken(t) {
|
|
359
|
+
const row = refreshTokens.get(t);
|
|
360
|
+
if (row) row.used = true;
|
|
361
|
+
},
|
|
362
|
+
loseNextRefreshRace() {
|
|
363
|
+
loseNextRace = true;
|
|
364
|
+
},
|
|
365
|
+
get reuses() {
|
|
366
|
+
return reuses;
|
|
367
|
+
},
|
|
368
|
+
revokeAll() {
|
|
369
|
+
for (const row of access.values()) row.revoked = true;
|
|
370
|
+
for (const row of refreshTokens.values()) row.revoked = true;
|
|
371
|
+
},
|
|
372
|
+
liveRefreshTokens() {
|
|
373
|
+
return [...refreshTokens].filter(([, r]) => !r.used && !r.revoked).map(([t]) => t);
|
|
374
|
+
},
|
|
375
|
+
reset() {
|
|
376
|
+
requests.length = 0;
|
|
377
|
+
writes.length = 0;
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo data for the mock pod. Shapes are the `@synap-core/sdk/public` wire
|
|
3
|
+
* types, so a fixture that drifts from the contract fails to compile.
|
|
4
|
+
*/
|
|
5
|
+
import type { PublicForm, PublicRecord, PublicShare } from "../public/client";
|
|
6
|
+
|
|
7
|
+
export const MOCK_FORM: PublicForm = {
|
|
8
|
+
title: "Get in touch",
|
|
9
|
+
fields: [
|
|
10
|
+
{ key: "name", label: "Name", type: "text", required: true, constraints: { maxLength: 120 } },
|
|
11
|
+
{ key: "email", label: "Email", type: "email", required: true },
|
|
12
|
+
{
|
|
13
|
+
key: "topic",
|
|
14
|
+
label: "Topic",
|
|
15
|
+
type: "enum",
|
|
16
|
+
constraints: { enum: ["Sales", "Support", "Partnership"] },
|
|
17
|
+
},
|
|
18
|
+
{ key: "message", label: "Message", type: "richtext", help: "A few lines is plenty." },
|
|
19
|
+
],
|
|
20
|
+
successMessage: "Thanks — we received your message.",
|
|
21
|
+
honeypotField: "hp",
|
|
22
|
+
ticket: "mock-ticket",
|
|
23
|
+
minSubmitMs: 0,
|
|
24
|
+
captcha: { required: false },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const MOCK_SHARE: PublicShare = {
|
|
28
|
+
resourceType: "entity",
|
|
29
|
+
title: "Launch notes",
|
|
30
|
+
properties: { status: "open", owner: "Ada", featured: true },
|
|
31
|
+
body: {
|
|
32
|
+
format: "markdown",
|
|
33
|
+
content: [
|
|
34
|
+
"# Launch notes",
|
|
35
|
+
"",
|
|
36
|
+
"This page is a **published snapshot** from a Synap pod.",
|
|
37
|
+
"",
|
|
38
|
+
"- Read the [Synap docs](https://synap.live)",
|
|
39
|
+
"- Links other than http, https and mailto render as plain text: [bad](javascript:alert(1))",
|
|
40
|
+
"",
|
|
41
|
+
"<script>raw HTML is shown as text, never run</script>",
|
|
42
|
+
].join("\n"),
|
|
43
|
+
},
|
|
44
|
+
publishedOn: "2026-09-27",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** One collection of a mock site: its door config and its live records. */
|
|
48
|
+
export interface MockCollection {
|
|
49
|
+
/** Default sort keys (`-` = descending); also the sort keys the door accepts. */
|
|
50
|
+
sort: string[];
|
|
51
|
+
pageSize: number;
|
|
52
|
+
addressing: "slug" | "publicId";
|
|
53
|
+
records: PublicRecord[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A mock published site. The manifest is derived from it (counts included). */
|
|
57
|
+
export interface MockSite {
|
|
58
|
+
title?: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
publishedOn: string;
|
|
61
|
+
collections: Record<string, MockCollection>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A public id of the door's shape (`p_` + 16 base62), deterministic. */
|
|
65
|
+
function publicId(prefix: string, n: number): string {
|
|
66
|
+
return `p_${prefix}${String(n).padStart(16 - prefix.length, "0")}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function slugify(s: string): string {
|
|
70
|
+
return s
|
|
71
|
+
.normalize("NFKD")
|
|
72
|
+
.replace(/[̀-ͯ]/g, "")
|
|
73
|
+
.toLowerCase()
|
|
74
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
75
|
+
.replace(/^-+|-+$/g, "")
|
|
76
|
+
.slice(0, 96);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const PAPER_TITLES = [
|
|
80
|
+
"Sparse graphs at scale",
|
|
81
|
+
"Learning to rank, revisited",
|
|
82
|
+
"A note on causal tracing",
|
|
83
|
+
"Small models, long contexts",
|
|
84
|
+
"Evaluating retrieval honestly",
|
|
85
|
+
"Robust tokenisation",
|
|
86
|
+
"Graphs that forget",
|
|
87
|
+
"Calibration under shift",
|
|
88
|
+
"Latent space cartography",
|
|
89
|
+
"Proofs as programs, again",
|
|
90
|
+
"Cheap uncertainty",
|
|
91
|
+
"Résumé of a decade",
|
|
92
|
+
];
|
|
93
|
+
const VENUES = ["NeurIPS", "ICML", "ACL", "ICLR"];
|
|
94
|
+
|
|
95
|
+
const PAPERS: PublicRecord[] = PAPER_TITLES.map((title, i) => ({
|
|
96
|
+
collection: "paper",
|
|
97
|
+
publicId: publicId("paper", i + 1),
|
|
98
|
+
slug: slugify(title),
|
|
99
|
+
title,
|
|
100
|
+
properties: {
|
|
101
|
+
year: 2026 - (i % 5),
|
|
102
|
+
venue: VENUES[i % VENUES.length],
|
|
103
|
+
open: i % 3 !== 0,
|
|
104
|
+
},
|
|
105
|
+
publishedOn: `2026-09-${String(28 - i).padStart(2, "0")}`,
|
|
106
|
+
body: i % 4 === 3 ? null : { format: "markdown", content: `# ${title}\n\nAbstract of **${title}**.` },
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
const PEOPLE: PublicRecord[] = ["Ada Lovelace", "Alan Turing", "Grace Hopper"].map((name, i) => ({
|
|
110
|
+
collection: "person",
|
|
111
|
+
publicId: publicId("person", i + 1),
|
|
112
|
+
slug: null,
|
|
113
|
+
title: name,
|
|
114
|
+
properties: { name, role: i === 0 ? "Lead" : "Researcher" },
|
|
115
|
+
publishedOn: "2026-09-01",
|
|
116
|
+
body: null,
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The demo site: `paper` (12 records, 5 per page, by slug: pagination),
|
|
121
|
+
* `person` (by public id), `event` (nothing published: the empty state).
|
|
122
|
+
*/
|
|
123
|
+
export const MOCK_SITE: MockSite = {
|
|
124
|
+
title: "Demo lab",
|
|
125
|
+
description: "What the lab publishes",
|
|
126
|
+
publishedOn: "2026-09-01",
|
|
127
|
+
collections: {
|
|
128
|
+
paper: { sort: ["-year"], pageSize: 5, addressing: "slug", records: PAPERS },
|
|
129
|
+
person: { sort: ["name"], pageSize: 20, addressing: "publicId", records: PEOPLE },
|
|
130
|
+
event: { sort: [], pageSize: 10, addressing: "slug", records: [] },
|
|
131
|
+
},
|
|
132
|
+
};
|