@synap-core/sdk 1.2.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 +80 -0
- package/dist/chunk-J7XHAAFJ.js +79 -0
- package/dist/chunk-J7XHAAFJ.js.map +1 -0
- package/dist/{client-C5sF20f1.d.cts → client-CVUT1Mcf.d.cts} +1 -1
- package/dist/{client-C5sF20f1.d.ts → client-CVUT1Mcf.d.ts} +1 -1
- 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/public.d.cts +2 -2
- package/dist/public.d.ts +2 -2
- package/dist/testing.cjs +304 -19
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +73 -2
- package/dist/testing.d.ts +73 -2
- package/dist/testing.js +281 -19
- package/dist/testing.js.map +1 -1
- package/dist/types-DpqPNaaa.d.cts +76 -0
- package/dist/types-DpqPNaaa.d.ts +76 -0
- package/package.json +11 -3
- 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/testing/auth-server.ts +380 -0
- package/src/testing/index.ts +5 -0
package/dist/testing.d.cts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { P as PublicForm,
|
|
1
|
+
import { P as PublicForm, a as PublicShare, b as PublicRecord } from './client-CVUT1Mcf.cjs';
|
|
2
|
+
import { P as PodGrant, a as PodUser } from './types-DpqPNaaa.cjs';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Demo data for the mock pod. Shapes are the `@synap-core/sdk/public` wire
|
|
@@ -28,6 +29,73 @@ interface MockSite {
|
|
|
28
29
|
*/
|
|
29
30
|
declare const MOCK_SITE: MockSite;
|
|
30
31
|
|
|
32
|
+
/** A placeholder pod origin; never contacted. */
|
|
33
|
+
declare const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
|
|
34
|
+
declare const MOCK_GRANT: PodGrant[];
|
|
35
|
+
declare const MOCK_USER: PodUser;
|
|
36
|
+
type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported" | "unreachable" | "server" | "unavailable";
|
|
37
|
+
interface MockAuthServerOptions {
|
|
38
|
+
issuer?: string;
|
|
39
|
+
/** Issue rotating refresh tokens (PB3). Default true; false = a pod before PB3. */
|
|
40
|
+
refresh?: boolean;
|
|
41
|
+
/** Default 3600 (the pod's 1 h). */
|
|
42
|
+
accessTtlSec?: number;
|
|
43
|
+
/** Default 30 days. */
|
|
44
|
+
refreshIdleSec?: number;
|
|
45
|
+
/** Send `refresh_token_expires_in` with each refresh token. Default false (the pod does not say). */
|
|
46
|
+
advertiseRefreshExpiry?: boolean;
|
|
47
|
+
grant?: PodGrant[];
|
|
48
|
+
user?: PodUser;
|
|
49
|
+
/** Override or remove (undefined) metadata fields: a pod too old, a lying issuer. */
|
|
50
|
+
metadata?: Record<string, unknown>;
|
|
51
|
+
/** Hub writes: "proposed" (ask-first, the default for an app) or "applied". */
|
|
52
|
+
writeMode?: "proposed" | "applied";
|
|
53
|
+
/** Hub paths (after /api/hub) that answer one failure. */
|
|
54
|
+
failures?: Record<string, MockHubFailure>;
|
|
55
|
+
now?: () => number;
|
|
56
|
+
}
|
|
57
|
+
interface MockAuthRequest {
|
|
58
|
+
method: string;
|
|
59
|
+
url: string;
|
|
60
|
+
/** Lower-cased header names. */
|
|
61
|
+
headers: Record<string, string>;
|
|
62
|
+
/** The form body of a /token or /revoke call. */
|
|
63
|
+
form: Record<string, string> | null;
|
|
64
|
+
}
|
|
65
|
+
interface AuthorizeDecision {
|
|
66
|
+
/** The person said no. */
|
|
67
|
+
deny?: boolean;
|
|
68
|
+
/** Override `iss` on the answer (null = omit it): a mix-up attack. */
|
|
69
|
+
iss?: string | null;
|
|
70
|
+
/** Override `state` on the answer: a forged answer. */
|
|
71
|
+
state?: string;
|
|
72
|
+
}
|
|
73
|
+
interface MockAuthServer {
|
|
74
|
+
issuer: string;
|
|
75
|
+
fetch: typeof fetch;
|
|
76
|
+
requests: MockAuthRequest[];
|
|
77
|
+
/** Hub writes seen, in order. */
|
|
78
|
+
writes: Array<{
|
|
79
|
+
method: string;
|
|
80
|
+
path: string;
|
|
81
|
+
body: unknown;
|
|
82
|
+
}>;
|
|
83
|
+
/** Stand-in for the consent screen: validates the request like /authorize, returns where the pod redirects. */
|
|
84
|
+
authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
|
|
85
|
+
/** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
|
|
86
|
+
consumeRefreshToken(token: string): void;
|
|
87
|
+
/** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
|
|
88
|
+
loseNextRefreshRace(): void;
|
|
89
|
+
/** Refresh-token redeems that were reuses (each revoked a family). */
|
|
90
|
+
reuses: number;
|
|
91
|
+
/** The person revoked the app: every token stops working. */
|
|
92
|
+
revokeAll(): void;
|
|
93
|
+
/** Live (unrevoked) refresh tokens. */
|
|
94
|
+
liveRefreshTokens(): string[];
|
|
95
|
+
reset(): void;
|
|
96
|
+
}
|
|
97
|
+
declare function createMockAuthServer(options?: MockAuthServerOptions): MockAuthServer;
|
|
98
|
+
|
|
31
99
|
/**
|
|
32
100
|
* @synap-core/sdk/testing — a mock pod: a fake `fetch` that answers the pod's
|
|
33
101
|
* public doors (forms, shares, sites) from fixtures, with no network. Inject
|
|
@@ -45,6 +113,9 @@ declare const MOCK_SITE: MockSite;
|
|
|
45
113
|
* tokens MOCK_TOKENS.form / .share / .site the demo form, share and site
|
|
46
114
|
* tokens MOCK_FAILURE_TOKENS.* one failure on every door
|
|
47
115
|
* anything else the uniform 404
|
|
116
|
+
*
|
|
117
|
+
* Pod-app sign-in (`@synap-core/sdk/connect`): `createMockAuthServer()`, see
|
|
118
|
+
* ./auth-server.ts.
|
|
48
119
|
*/
|
|
49
120
|
|
|
50
121
|
/** A placeholder origin; never contacted. */
|
|
@@ -107,4 +178,4 @@ interface MockPod {
|
|
|
107
178
|
}
|
|
108
179
|
declare function createMockPod(options?: MockPodOptions): MockPod;
|
|
109
180
|
|
|
110
|
-
export { MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, type MockCollection, type MockPod, type MockPodOptions, type MockRequest, type MockSite, type MockSubmission, createMockPod };
|
|
181
|
+
export { type AuthorizeDecision, MOCK_AUTH_ISSUER, MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_GRANT, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, MOCK_USER, type MockAuthRequest, type MockAuthServer, type MockAuthServerOptions, type MockCollection, type MockHubFailure, type MockPod, type MockPodOptions, type MockRequest, type MockSite, type MockSubmission, createMockAuthServer, createMockPod };
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { P as PublicForm,
|
|
1
|
+
import { P as PublicForm, a as PublicShare, b as PublicRecord } from './client-CVUT1Mcf.js';
|
|
2
|
+
import { P as PodGrant, a as PodUser } from './types-DpqPNaaa.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Demo data for the mock pod. Shapes are the `@synap-core/sdk/public` wire
|
|
@@ -28,6 +29,73 @@ interface MockSite {
|
|
|
28
29
|
*/
|
|
29
30
|
declare const MOCK_SITE: MockSite;
|
|
30
31
|
|
|
32
|
+
/** A placeholder pod origin; never contacted. */
|
|
33
|
+
declare const MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
|
|
34
|
+
declare const MOCK_GRANT: PodGrant[];
|
|
35
|
+
declare const MOCK_USER: PodUser;
|
|
36
|
+
type MockHubFailure = "rate_limited" | "forbidden" | "door_unsupported" | "unreachable" | "server" | "unavailable";
|
|
37
|
+
interface MockAuthServerOptions {
|
|
38
|
+
issuer?: string;
|
|
39
|
+
/** Issue rotating refresh tokens (PB3). Default true; false = a pod before PB3. */
|
|
40
|
+
refresh?: boolean;
|
|
41
|
+
/** Default 3600 (the pod's 1 h). */
|
|
42
|
+
accessTtlSec?: number;
|
|
43
|
+
/** Default 30 days. */
|
|
44
|
+
refreshIdleSec?: number;
|
|
45
|
+
/** Send `refresh_token_expires_in` with each refresh token. Default false (the pod does not say). */
|
|
46
|
+
advertiseRefreshExpiry?: boolean;
|
|
47
|
+
grant?: PodGrant[];
|
|
48
|
+
user?: PodUser;
|
|
49
|
+
/** Override or remove (undefined) metadata fields: a pod too old, a lying issuer. */
|
|
50
|
+
metadata?: Record<string, unknown>;
|
|
51
|
+
/** Hub writes: "proposed" (ask-first, the default for an app) or "applied". */
|
|
52
|
+
writeMode?: "proposed" | "applied";
|
|
53
|
+
/** Hub paths (after /api/hub) that answer one failure. */
|
|
54
|
+
failures?: Record<string, MockHubFailure>;
|
|
55
|
+
now?: () => number;
|
|
56
|
+
}
|
|
57
|
+
interface MockAuthRequest {
|
|
58
|
+
method: string;
|
|
59
|
+
url: string;
|
|
60
|
+
/** Lower-cased header names. */
|
|
61
|
+
headers: Record<string, string>;
|
|
62
|
+
/** The form body of a /token or /revoke call. */
|
|
63
|
+
form: Record<string, string> | null;
|
|
64
|
+
}
|
|
65
|
+
interface AuthorizeDecision {
|
|
66
|
+
/** The person said no. */
|
|
67
|
+
deny?: boolean;
|
|
68
|
+
/** Override `iss` on the answer (null = omit it): a mix-up attack. */
|
|
69
|
+
iss?: string | null;
|
|
70
|
+
/** Override `state` on the answer: a forged answer. */
|
|
71
|
+
state?: string;
|
|
72
|
+
}
|
|
73
|
+
interface MockAuthServer {
|
|
74
|
+
issuer: string;
|
|
75
|
+
fetch: typeof fetch;
|
|
76
|
+
requests: MockAuthRequest[];
|
|
77
|
+
/** Hub writes seen, in order. */
|
|
78
|
+
writes: Array<{
|
|
79
|
+
method: string;
|
|
80
|
+
path: string;
|
|
81
|
+
body: unknown;
|
|
82
|
+
}>;
|
|
83
|
+
/** Stand-in for the consent screen: validates the request like /authorize, returns where the pod redirects. */
|
|
84
|
+
authorize(authorizeUrl: string, decision?: AuthorizeDecision): string;
|
|
85
|
+
/** Mark a refresh token used elsewhere: presenting it now is a REUSE (family + live key revoked). */
|
|
86
|
+
consumeRefreshToken(token: string): void;
|
|
87
|
+
/** The next refresh redeem loses a concurrent race: invalid_grant, nothing revoked. */
|
|
88
|
+
loseNextRefreshRace(): void;
|
|
89
|
+
/** Refresh-token redeems that were reuses (each revoked a family). */
|
|
90
|
+
reuses: number;
|
|
91
|
+
/** The person revoked the app: every token stops working. */
|
|
92
|
+
revokeAll(): void;
|
|
93
|
+
/** Live (unrevoked) refresh tokens. */
|
|
94
|
+
liveRefreshTokens(): string[];
|
|
95
|
+
reset(): void;
|
|
96
|
+
}
|
|
97
|
+
declare function createMockAuthServer(options?: MockAuthServerOptions): MockAuthServer;
|
|
98
|
+
|
|
31
99
|
/**
|
|
32
100
|
* @synap-core/sdk/testing — a mock pod: a fake `fetch` that answers the pod's
|
|
33
101
|
* public doors (forms, shares, sites) from fixtures, with no network. Inject
|
|
@@ -45,6 +113,9 @@ declare const MOCK_SITE: MockSite;
|
|
|
45
113
|
* tokens MOCK_TOKENS.form / .share / .site the demo form, share and site
|
|
46
114
|
* tokens MOCK_FAILURE_TOKENS.* one failure on every door
|
|
47
115
|
* anything else the uniform 404
|
|
116
|
+
*
|
|
117
|
+
* Pod-app sign-in (`@synap-core/sdk/connect`): `createMockAuthServer()`, see
|
|
118
|
+
* ./auth-server.ts.
|
|
48
119
|
*/
|
|
49
120
|
|
|
50
121
|
/** A placeholder origin; never contacted. */
|
|
@@ -107,4 +178,4 @@ interface MockPod {
|
|
|
107
178
|
}
|
|
108
179
|
declare function createMockPod(options?: MockPodOptions): MockPod;
|
|
109
180
|
|
|
110
|
-
export { MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, type MockCollection, type MockPod, type MockPodOptions, type MockRequest, type MockSite, type MockSubmission, createMockPod };
|
|
181
|
+
export { type AuthorizeDecision, MOCK_AUTH_ISSUER, MOCK_FAILURE_TOKENS, MOCK_FORM, MOCK_GRANT, MOCK_POD_URL, MOCK_RETRY_AFTER_SEC, MOCK_SHARE, MOCK_SITE, MOCK_TOKENS, MOCK_USER, type MockAuthRequest, type MockAuthServer, type MockAuthServerOptions, type MockCollection, type MockHubFailure, type MockPod, type MockPodOptions, type MockRequest, type MockSite, type MockSubmission, createMockAuthServer, createMockPod };
|
package/dist/testing.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pkceChallenge,
|
|
3
|
+
randomToken
|
|
4
|
+
} from "./chunk-J7XHAAFJ.js";
|
|
5
|
+
|
|
1
6
|
// src/testing/fixtures.ts
|
|
2
7
|
var MOCK_FORM = {
|
|
3
8
|
title: "Get in touch",
|
|
@@ -93,6 +98,259 @@ var MOCK_SITE = {
|
|
|
93
98
|
}
|
|
94
99
|
};
|
|
95
100
|
|
|
101
|
+
// src/testing/auth-server.ts
|
|
102
|
+
var MOCK_AUTH_ISSUER = "https://pod.mock-synap.test";
|
|
103
|
+
var MOCK_GRANT = [
|
|
104
|
+
{ permission: "entity.post.read", workspaceId: "ws_mock_studio", workspaceName: "Studio" },
|
|
105
|
+
{ permission: "entity.post.create", workspaceId: "ws_mock_studio", workspaceName: "Studio" }
|
|
106
|
+
];
|
|
107
|
+
var MOCK_USER = { id: "user_mock_ada", name: "Ada Lovelace" };
|
|
108
|
+
function json(status, body, headers = {}) {
|
|
109
|
+
return new Response(JSON.stringify(body), {
|
|
110
|
+
status,
|
|
111
|
+
headers: { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers }
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function oauthError(error, description, status = 400) {
|
|
115
|
+
return json(status, { error, error_description: description });
|
|
116
|
+
}
|
|
117
|
+
function createMockAuthServer(options = {}) {
|
|
118
|
+
const issuer = options.issuer ?? MOCK_AUTH_ISSUER;
|
|
119
|
+
const now = options.now ?? Date.now;
|
|
120
|
+
const refresh = options.refresh ?? true;
|
|
121
|
+
const accessTtl = options.accessTtlSec ?? 3600;
|
|
122
|
+
const refreshIdle = options.refreshIdleSec ?? 30 * 24 * 3600;
|
|
123
|
+
const grant = options.grant ?? MOCK_GRANT;
|
|
124
|
+
const user = options.user ?? MOCK_USER;
|
|
125
|
+
const codes = /* @__PURE__ */ new Map();
|
|
126
|
+
const access = /* @__PURE__ */ new Map();
|
|
127
|
+
const refreshTokens = /* @__PURE__ */ new Map();
|
|
128
|
+
const requests = [];
|
|
129
|
+
const writes = [];
|
|
130
|
+
let proposalSeq = 0;
|
|
131
|
+
const metadata = () => {
|
|
132
|
+
const base = {
|
|
133
|
+
issuer,
|
|
134
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
135
|
+
token_endpoint: `${issuer}/token`,
|
|
136
|
+
registration_endpoint: `${issuer}/register`,
|
|
137
|
+
revocation_endpoint: `${issuer}/revoke`,
|
|
138
|
+
revocation_endpoint_auth_methods_supported: ["none"],
|
|
139
|
+
response_types_supported: ["code"],
|
|
140
|
+
grant_types_supported: refresh ? ["authorization_code", "refresh_token"] : ["authorization_code"],
|
|
141
|
+
code_challenge_methods_supported: ["S256"],
|
|
142
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
143
|
+
authorization_response_iss_parameter_supported: true,
|
|
144
|
+
client_id_metadata_document_supported: true
|
|
145
|
+
};
|
|
146
|
+
for (const [k, v] of Object.entries(options.metadata ?? {})) {
|
|
147
|
+
if (v === void 0) delete base[k];
|
|
148
|
+
else base[k] = v;
|
|
149
|
+
}
|
|
150
|
+
return base;
|
|
151
|
+
};
|
|
152
|
+
let loseNextRace = false;
|
|
153
|
+
let reuses = 0;
|
|
154
|
+
const revokeFamily = (family) => {
|
|
155
|
+
for (const r of refreshTokens.values()) if (r.family === family) r.revoked = true;
|
|
156
|
+
for (const a of access.values()) if (a.family === family) a.revoked = true;
|
|
157
|
+
};
|
|
158
|
+
const tokenBody = (scope, clientId, family) => {
|
|
159
|
+
const accessToken = `synap_hub_mock_${randomToken()}`;
|
|
160
|
+
access.set(accessToken, { expiresAt: now() + accessTtl * 1e3, revoked: false, family: refresh ? family : null });
|
|
161
|
+
const body = {
|
|
162
|
+
access_token: accessToken,
|
|
163
|
+
token_type: "Bearer",
|
|
164
|
+
expires_in: accessTtl,
|
|
165
|
+
scope: scope.join(" "),
|
|
166
|
+
synap_grant: grant.filter((g) => scope.includes(g.permission)),
|
|
167
|
+
synap_user: user
|
|
168
|
+
};
|
|
169
|
+
if (refresh) {
|
|
170
|
+
const refreshToken = randomToken();
|
|
171
|
+
refreshTokens.set(refreshToken, { clientId, family, idleUntil: now() + refreshIdle * 1e3, used: false, revoked: false, scope });
|
|
172
|
+
body.refresh_token = refreshToken;
|
|
173
|
+
if (options.advertiseRefreshExpiry) body.refresh_token_expires_in = refreshIdle;
|
|
174
|
+
}
|
|
175
|
+
return body;
|
|
176
|
+
};
|
|
177
|
+
async function token(form) {
|
|
178
|
+
if (form.grant_type === "authorization_code") {
|
|
179
|
+
const row = form.code ? codes.get(form.code) : void 0;
|
|
180
|
+
if (form.code) codes.delete(form.code);
|
|
181
|
+
if (!row) return oauthError("invalid_grant", "Invalid, expired, or already-redeemed authorization code");
|
|
182
|
+
if (row.clientId !== form.client_id) return oauthError("invalid_grant", "client_id does not match the code");
|
|
183
|
+
if (row.redirectUri !== form.redirect_uri) return oauthError("invalid_grant", "redirect_uri mismatch");
|
|
184
|
+
if (!form.code_verifier || await pkceChallenge(form.code_verifier) !== row.challenge) {
|
|
185
|
+
return oauthError("invalid_grant", "PKCE verification failed");
|
|
186
|
+
}
|
|
187
|
+
if (form.resource !== void 0 && row.resource && form.resource !== row.resource) {
|
|
188
|
+
return oauthError("invalid_target", "resource does not match the authorization request");
|
|
189
|
+
}
|
|
190
|
+
for (const r of refreshTokens.values()) if (r.clientId === row.clientId) revokeFamily(r.family);
|
|
191
|
+
return json(200, tokenBody(row.scope, row.clientId, randomToken()));
|
|
192
|
+
}
|
|
193
|
+
if (form.grant_type === "refresh_token") {
|
|
194
|
+
if (!refresh) return oauthError("unsupported_grant_type", 'Only grant_type "authorization_code" is supported');
|
|
195
|
+
if (!form.refresh_token || !form.client_id) return oauthError("invalid_request", "refresh_token and client_id are required");
|
|
196
|
+
const row = refreshTokens.get(form.refresh_token);
|
|
197
|
+
if (!row || row.clientId !== form.client_id || row.revoked) {
|
|
198
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
199
|
+
}
|
|
200
|
+
if (row.used) {
|
|
201
|
+
reuses++;
|
|
202
|
+
revokeFamily(row.family);
|
|
203
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
204
|
+
}
|
|
205
|
+
if (row.idleUntil <= now()) return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
206
|
+
if (loseNextRace) {
|
|
207
|
+
loseNextRace = false;
|
|
208
|
+
return oauthError("invalid_grant", "Invalid, expired, revoked or already-used refresh token");
|
|
209
|
+
}
|
|
210
|
+
row.used = true;
|
|
211
|
+
return json(200, tokenBody(row.scope, row.clientId, row.family));
|
|
212
|
+
}
|
|
213
|
+
return oauthError("unsupported_grant_type", "Unsupported grant_type");
|
|
214
|
+
}
|
|
215
|
+
function hub(method, path, headers, body) {
|
|
216
|
+
const failure2 = options.failures?.[path];
|
|
217
|
+
if (failure2 === "unreachable") return "reject";
|
|
218
|
+
const bearer = /^Bearer (.+)$/.exec(headers.authorization ?? "")?.[1];
|
|
219
|
+
const row = bearer ? access.get(bearer) : void 0;
|
|
220
|
+
if (!row || row.revoked || row.expiresAt <= now()) return json(401, { error: "Unauthorized" });
|
|
221
|
+
switch (failure2) {
|
|
222
|
+
case "rate_limited":
|
|
223
|
+
return json(429, { error: "Too many requests" }, { "Retry-After": "30" });
|
|
224
|
+
case "forbidden":
|
|
225
|
+
return json(403, { error: "Forbidden" });
|
|
226
|
+
case "door_unsupported":
|
|
227
|
+
return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
|
|
228
|
+
case "server":
|
|
229
|
+
return json(500, { error: "Internal error" });
|
|
230
|
+
case "unavailable":
|
|
231
|
+
return json(503, { error: "Unavailable" });
|
|
232
|
+
}
|
|
233
|
+
if (path.startsWith("/knowledge/") || path === "/entities/retrieve" || path === "/memory/search") {
|
|
234
|
+
return json(403, { error: "This door is not available to a space-scoped key", code: "GRANT_DOOR_UNSUPPORTED" });
|
|
235
|
+
}
|
|
236
|
+
if (method === "GET" && path === "/users/me") return json(200, user);
|
|
237
|
+
if (method === "GET" && path === "/entities") return json(200, { entities: [], total: 0 });
|
|
238
|
+
if (method !== "GET") {
|
|
239
|
+
writes.push({ method, path, body });
|
|
240
|
+
if ((options.writeMode ?? "proposed") === "proposed") {
|
|
241
|
+
const proposalId = `prop_mock_${++proposalSeq}`;
|
|
242
|
+
return json(200, {
|
|
243
|
+
status: "proposed",
|
|
244
|
+
message: "Sent for review",
|
|
245
|
+
proposalId,
|
|
246
|
+
reviewUrl: `${issuer}/open/${proposalId}`
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return json(200, { status: "created", id: `ent_mock_${writes.length}` });
|
|
250
|
+
}
|
|
251
|
+
return json(404, { error: "Not found" });
|
|
252
|
+
}
|
|
253
|
+
const mockFetch = async (input, init) => {
|
|
254
|
+
const request = typeof input === "object" && "method" in input ? input : null;
|
|
255
|
+
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
|
256
|
+
const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
|
|
257
|
+
const headers = {};
|
|
258
|
+
new Headers(init?.headers ?? request?.headers).forEach((value, name) => {
|
|
259
|
+
headers[name.toLowerCase()] = value;
|
|
260
|
+
});
|
|
261
|
+
const rawBody = typeof init?.body === "string" ? init.body : request ? await request.clone().text() : "";
|
|
262
|
+
const isForm = (headers["content-type"] ?? "").includes("application/x-www-form-urlencoded");
|
|
263
|
+
const form = isForm ? Object.fromEntries(new URLSearchParams(rawBody)) : null;
|
|
264
|
+
requests.push({ method, url: url.href, headers, form });
|
|
265
|
+
if (url.origin !== new URL(issuer).origin) throw new TypeError(`Failed to fetch: ${url.origin} is not the mock pod`);
|
|
266
|
+
const path = url.pathname;
|
|
267
|
+
if (method === "GET" && path === "/.well-known/oauth-authorization-server") return json(200, metadata());
|
|
268
|
+
if (method === "POST" && path === "/token") return token(form ?? {});
|
|
269
|
+
if (method === "POST" && path === "/revoke") {
|
|
270
|
+
const t = form?.token ?? "";
|
|
271
|
+
const r = refreshTokens.get(t);
|
|
272
|
+
if (r && form?.client_id && form.client_id !== r.clientId) {
|
|
273
|
+
return oauthError("unauthorized_client", "The token was not issued to this client");
|
|
274
|
+
}
|
|
275
|
+
if (r) revokeFamily(r.family);
|
|
276
|
+
const a = access.get(t);
|
|
277
|
+
if (a) a.revoked = true;
|
|
278
|
+
return new Response(null, { status: 200 });
|
|
279
|
+
}
|
|
280
|
+
const hubPath = /^\/api\/hub(\/.*)$/.exec(path)?.[1];
|
|
281
|
+
if (hubPath) {
|
|
282
|
+
let body = void 0;
|
|
283
|
+
try {
|
|
284
|
+
body = rawBody ? JSON.parse(rawBody) : void 0;
|
|
285
|
+
} catch {
|
|
286
|
+
body = rawBody;
|
|
287
|
+
}
|
|
288
|
+
const res = hub(method, hubPath, headers, body);
|
|
289
|
+
if (res === "reject") throw new TypeError("Failed to fetch: mock pod unreachable");
|
|
290
|
+
return res;
|
|
291
|
+
}
|
|
292
|
+
return json(404, { error: "Not found" });
|
|
293
|
+
};
|
|
294
|
+
return {
|
|
295
|
+
issuer,
|
|
296
|
+
fetch: mockFetch,
|
|
297
|
+
requests,
|
|
298
|
+
writes,
|
|
299
|
+
authorize(authorizeUrl, decision = {}) {
|
|
300
|
+
const url = new URL(authorizeUrl);
|
|
301
|
+
if (`${url.origin}${url.pathname}` !== `${issuer}/authorize`) throw new Error(`Not this pod's /authorize: ${authorizeUrl}`);
|
|
302
|
+
const q = url.searchParams;
|
|
303
|
+
const redirectUri = q.get("redirect_uri");
|
|
304
|
+
const clientId = q.get("client_id");
|
|
305
|
+
if (!clientId || !redirectUri) throw new Error("Authorization request rejected: client_id and redirect_uri are required");
|
|
306
|
+
const answer = new URL(redirectUri);
|
|
307
|
+
const state = decision.state ?? q.get("state");
|
|
308
|
+
const finish = (fields) => {
|
|
309
|
+
for (const [k, v] of Object.entries(fields)) answer.searchParams.set(k, v);
|
|
310
|
+
if (state !== null) answer.searchParams.set("state", state);
|
|
311
|
+
const iss = decision.iss === void 0 ? issuer : decision.iss;
|
|
312
|
+
if (iss !== null) answer.searchParams.set("iss", iss);
|
|
313
|
+
return answer.href;
|
|
314
|
+
};
|
|
315
|
+
if (q.get("response_type") !== "code") return finish({ error: "unsupported_response_type" });
|
|
316
|
+
if (q.get("code_challenge_method") !== "S256" || !q.get("code_challenge")) {
|
|
317
|
+
return finish({ error: "invalid_request", error_description: "PKCE S256 is required" });
|
|
318
|
+
}
|
|
319
|
+
const resource = q.get("resource");
|
|
320
|
+
if (resource !== null && resource !== `${issuer}/api/hub` && resource !== `${issuer}/mcp`) {
|
|
321
|
+
return finish({ error: "invalid_target" });
|
|
322
|
+
}
|
|
323
|
+
const scope = (q.get("scope") ?? "").split(/\s+/).filter(Boolean);
|
|
324
|
+
if (scope.length === 0) return finish({ error: "invalid_scope" });
|
|
325
|
+
if (decision.deny) return finish({ error: "access_denied" });
|
|
326
|
+
const code = randomToken();
|
|
327
|
+
codes.set(code, { clientId, redirectUri, challenge: q.get("code_challenge"), resource, scope });
|
|
328
|
+
return finish({ code });
|
|
329
|
+
},
|
|
330
|
+
consumeRefreshToken(t) {
|
|
331
|
+
const row = refreshTokens.get(t);
|
|
332
|
+
if (row) row.used = true;
|
|
333
|
+
},
|
|
334
|
+
loseNextRefreshRace() {
|
|
335
|
+
loseNextRace = true;
|
|
336
|
+
},
|
|
337
|
+
get reuses() {
|
|
338
|
+
return reuses;
|
|
339
|
+
},
|
|
340
|
+
revokeAll() {
|
|
341
|
+
for (const row of access.values()) row.revoked = true;
|
|
342
|
+
for (const row of refreshTokens.values()) row.revoked = true;
|
|
343
|
+
},
|
|
344
|
+
liveRefreshTokens() {
|
|
345
|
+
return [...refreshTokens].filter(([, r]) => !r.used && !r.revoked).map(([t]) => t);
|
|
346
|
+
},
|
|
347
|
+
reset() {
|
|
348
|
+
requests.length = 0;
|
|
349
|
+
writes.length = 0;
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
96
354
|
// src/testing/index.ts
|
|
97
355
|
var MOCK_POD_URL = "https://mock.synap.invalid";
|
|
98
356
|
var MOCK_TOKENS = { form: "demo-form", share: "demo", site: "demo-site" };
|
|
@@ -127,7 +385,7 @@ var MAX_OFFSET = 1e4;
|
|
|
127
385
|
var MAX_LIMIT = 100;
|
|
128
386
|
var MAX_SORT_KEYS = 3;
|
|
129
387
|
var PUBLISHED_AT = "publishedAt";
|
|
130
|
-
function
|
|
388
|
+
function json2(status, body, headers = {}) {
|
|
131
389
|
return new Response(JSON.stringify(body), {
|
|
132
390
|
status,
|
|
133
391
|
headers: { "Content-Type": "application/json", "Cache-Control": "no-cache", ...headers }
|
|
@@ -150,15 +408,15 @@ function etagMatches(header, etag) {
|
|
|
150
408
|
function failure(token) {
|
|
151
409
|
switch (token) {
|
|
152
410
|
case MOCK_FAILURE_TOKENS.rateLimited:
|
|
153
|
-
return
|
|
411
|
+
return json2(429, { error: "Too many requests", retryAfter: "5 minutes" });
|
|
154
412
|
case MOCK_FAILURE_TOKENS.rateLimitedRetryAfter:
|
|
155
|
-
return
|
|
413
|
+
return json2(429, { error: "Too many requests" }, { "Retry-After": String(MOCK_RETRY_AFTER_SEC) });
|
|
156
414
|
case MOCK_FAILURE_TOKENS.doorsDisabled:
|
|
157
|
-
return
|
|
415
|
+
return json2(403, DOORS_DISABLED);
|
|
158
416
|
case MOCK_FAILURE_TOKENS.serverError:
|
|
159
|
-
return
|
|
417
|
+
return json2(500, { error: "Internal error" });
|
|
160
418
|
case MOCK_FAILURE_TOKENS.unavailable:
|
|
161
|
-
return
|
|
419
|
+
return json2(503, { received: false });
|
|
162
420
|
case MOCK_FAILURE_TOKENS.unreachable:
|
|
163
421
|
return "reject";
|
|
164
422
|
default:
|
|
@@ -233,17 +491,17 @@ function cached(view, ifNoneMatch) {
|
|
|
233
491
|
if (etagMatches(ifNoneMatch, etag)) {
|
|
234
492
|
return new Response(null, { status: 304, headers: { ETag: etag, "Cache-Control": "no-cache" } });
|
|
235
493
|
}
|
|
236
|
-
return
|
|
494
|
+
return json2(200, view, { ETag: etag });
|
|
237
495
|
}
|
|
238
496
|
function siteDoor(site, rest, params, ifNoneMatch) {
|
|
239
497
|
if (rest.length === 0) return cached(manifest(site), ifNoneMatch);
|
|
240
498
|
if (rest[0] !== "collections" || rest.length > 3 || !Object.hasOwn(site.collections, rest[1])) {
|
|
241
|
-
return
|
|
499
|
+
return json2(404, NOT_FOUND);
|
|
242
500
|
}
|
|
243
501
|
const c = site.collections[rest[1]];
|
|
244
502
|
if (rest.length === 2) {
|
|
245
503
|
const q = parseQuery(params, c);
|
|
246
|
-
if (typeof q === "string") return
|
|
504
|
+
if (typeof q === "string") return json2(400, { error: q });
|
|
247
505
|
const sorted = [...c.records].sort(compareRecords(q.sort));
|
|
248
506
|
const page = {
|
|
249
507
|
items: sorted.slice(q.offset, q.offset + q.limit).map(summary),
|
|
@@ -255,10 +513,10 @@ function siteDoor(site, rest, params, ifNoneMatch) {
|
|
|
255
513
|
}
|
|
256
514
|
const address = rest[2];
|
|
257
515
|
const byPublicId = PUBLIC_ID_RE.test(address);
|
|
258
|
-
if (!byPublicId && !(address.length <= SLUG_MAX_LENGTH && SLUG_RE.test(address))) return
|
|
516
|
+
if (!byPublicId && !(address.length <= SLUG_MAX_LENGTH && SLUG_RE.test(address))) return json2(404, NOT_FOUND);
|
|
259
517
|
const record = c.records.find((r) => byPublicId ? r.publicId === address : r.slug === address);
|
|
260
|
-
if (!record) return
|
|
261
|
-
if (params.has("embed")) return
|
|
518
|
+
if (!record) return json2(404, NOT_FOUND);
|
|
519
|
+
if (params.has("embed")) return json2(400, { error: "embed is not available" });
|
|
262
520
|
return cached(record, ifNoneMatch);
|
|
263
521
|
}
|
|
264
522
|
function invalidKeys(form, body) {
|
|
@@ -295,12 +553,12 @@ function createMockPod(options = {}) {
|
|
|
295
553
|
if (preflighted.length > 0) throw new TypeError(`Failed to fetch: preflight refuses ${preflighted.join(", ")}`);
|
|
296
554
|
}
|
|
297
555
|
const m = url.pathname.match(/^\/api\/hub\/public\/(forms|shares|sites)\/(.+)$/);
|
|
298
|
-
if (!m) return
|
|
556
|
+
if (!m) return json2(404, NOT_FOUND);
|
|
299
557
|
let segments;
|
|
300
558
|
try {
|
|
301
559
|
segments = m[2].split("/").map(decodeURIComponent);
|
|
302
560
|
} catch {
|
|
303
|
-
return
|
|
561
|
+
return json2(404, NOT_FOUND);
|
|
304
562
|
}
|
|
305
563
|
const [token, ...rest] = segments;
|
|
306
564
|
const failed = failure(token);
|
|
@@ -318,15 +576,15 @@ function createMockPod(options = {}) {
|
|
|
318
576
|
const invalid = form ? invalidKeys(form, body) : [];
|
|
319
577
|
const status = invalid.length > 0 ? 422 : 202;
|
|
320
578
|
submissions.push({ token, body, status });
|
|
321
|
-
return status === 422 ?
|
|
579
|
+
return status === 422 ? json2(422, { received: false, invalid }) : json2(202, { received: true });
|
|
322
580
|
}
|
|
323
|
-
if (method !== "GET") return
|
|
324
|
-
if (door === "forms" && rest.length === 0 && Object.hasOwn(forms, token)) return
|
|
325
|
-
if (door === "shares" && rest.length === 0 && Object.hasOwn(shares, token)) return
|
|
581
|
+
if (method !== "GET") return json2(404, NOT_FOUND);
|
|
582
|
+
if (door === "forms" && rest.length === 0 && Object.hasOwn(forms, token)) return json2(200, forms[token]);
|
|
583
|
+
if (door === "shares" && rest.length === 0 && Object.hasOwn(shares, token)) return json2(200, shares[token]);
|
|
326
584
|
if (door === "sites" && Object.hasOwn(sites, token)) {
|
|
327
585
|
return siteDoor(sites[token], rest, url.searchParams, headers["if-none-match"] ?? null);
|
|
328
586
|
}
|
|
329
|
-
return
|
|
587
|
+
return json2(404, NOT_FOUND);
|
|
330
588
|
};
|
|
331
589
|
return {
|
|
332
590
|
podUrl: MOCK_POD_URL,
|
|
@@ -340,13 +598,17 @@ function createMockPod(options = {}) {
|
|
|
340
598
|
};
|
|
341
599
|
}
|
|
342
600
|
export {
|
|
601
|
+
MOCK_AUTH_ISSUER,
|
|
343
602
|
MOCK_FAILURE_TOKENS,
|
|
344
603
|
MOCK_FORM,
|
|
604
|
+
MOCK_GRANT,
|
|
345
605
|
MOCK_POD_URL,
|
|
346
606
|
MOCK_RETRY_AFTER_SEC,
|
|
347
607
|
MOCK_SHARE,
|
|
348
608
|
MOCK_SITE,
|
|
349
609
|
MOCK_TOKENS,
|
|
610
|
+
MOCK_USER,
|
|
611
|
+
createMockAuthServer,
|
|
350
612
|
createMockPod
|
|
351
613
|
};
|
|
352
614
|
//# sourceMappingURL=testing.js.map
|