@sovereignfs/sdk 1.25.0 → 2.0.1
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/LICENSE +373 -661
- package/README.md +103 -0
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +4 -0
- package/dist/chunk-NYL4LLXF.js +65 -0
- package/dist/index.d.ts +57 -529
- package/dist/index.js +1283 -572
- package/dist/next.d.ts +37 -0
- package/dist/next.js +68 -0
- package/dist/session-DJl2XYMc.d.ts +44 -0
- package/dist/sovereign-CazGKEVc.d.ts +517 -0
- package/dist/testing.d.ts +181 -0
- package/dist/testing.js +560 -0
- package/package.json +53 -27
- package/dist/chunk-MLKGABMK.js +0 -9
- package/dist/chunk-NQLGPAL6.js +0 -62
- package/dist/e2ee-crypto.d.ts +0 -84
- package/dist/e2ee-crypto.js +0 -162
- package/dist/e2ee-device.d.ts +0 -21
- package/dist/e2ee-device.js +0 -13
- package/dist/e2ee-object.d.ts +0 -50
- package/dist/e2ee-object.js +0 -75
- package/dist/e2ee-state-U-GEJ7oL.d.ts +0 -411
- package/dist/e2ee-state.d.ts +0 -1
- package/dist/e2ee-state.js +0 -36
- package/src/__tests__/e2ee-crypto.test.ts +0 -156
- package/src/__tests__/e2ee-object.test.ts +0 -90
- package/src/__tests__/e2ee-state.test.ts +0 -83
- package/src/__tests__/sdk.test.ts +0 -427
- package/src/__tests__/sessions.test.ts +0 -63
- package/src/activity.ts +0 -19
- package/src/auth.ts +0 -134
- package/src/connections.ts +0 -106
- package/src/data.ts +0 -88
- package/src/db.ts +0 -34
- package/src/directory.ts +0 -31
- package/src/e2ee-crypto.ts +0 -260
- package/src/e2ee-device.ts +0 -75
- package/src/e2ee-object.ts +0 -118
- package/src/e2ee-state.ts +0 -72
- package/src/e2ee.ts +0 -65
- package/src/env.ts +0 -37
- package/src/errors.ts +0 -46
- package/src/host.ts +0 -251
- package/src/index.ts +0 -160
- package/src/mailer.ts +0 -7
- package/src/notifications.ts +0 -36
- package/src/platform.ts +0 -10
- package/src/plugins.ts +0 -146
- package/src/portability.ts +0 -176
- package/src/secrets.ts +0 -68
- package/src/sessions.ts +0 -36
- package/src/storage.ts +0 -49
- package/src/types.ts +0 -418
- package/src/unimplemented.ts +0 -48
package/dist/testing.js
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
// src/testing/fake-core.ts
|
|
2
|
+
import { SignJWT, exportJWK, generateKeyPair } from "jose";
|
|
3
|
+
async function challengeOf(verifier) {
|
|
4
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
5
|
+
return btoa(String.fromCharCode(...new Uint8Array(digest))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
6
|
+
}
|
|
7
|
+
var random = (prefix) => `${prefix}_${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
8
|
+
var json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
|
|
9
|
+
status,
|
|
10
|
+
headers: { "content-type": "application/json", ...headers }
|
|
11
|
+
});
|
|
12
|
+
var problem = (status, error, message, headers = {}) => json({ error, message }, status, headers);
|
|
13
|
+
function createFakeCore(options = {}) {
|
|
14
|
+
const url = new URL(options.url ?? "https://core.test").origin;
|
|
15
|
+
const credentials = {
|
|
16
|
+
clientId: options.clientId ?? "fake-app",
|
|
17
|
+
clientSecret: options.clientSecret ?? "fake-secret"
|
|
18
|
+
};
|
|
19
|
+
const now = options.now ?? (() => Date.now());
|
|
20
|
+
const lifetime = options.accessTokenSeconds ?? 900;
|
|
21
|
+
const api = "api" in options ? options.api : { versions: ["v1"], capabilities: ["directory", "notifications", "email", "activity"] };
|
|
22
|
+
const appTokens = options.appTokens ?? true;
|
|
23
|
+
const people = /* @__PURE__ */ new Map();
|
|
24
|
+
const codes = /* @__PURE__ */ new Map();
|
|
25
|
+
const sessionOf = /* @__PURE__ */ new Map();
|
|
26
|
+
let signing;
|
|
27
|
+
const keys = () => signing ??= generateKeyPair("EdDSA", { extractable: true }).then(async (pair) => ({
|
|
28
|
+
privateKey: pair.privateKey,
|
|
29
|
+
publicJwk: { ...await exportJWK(pair.publicKey), kid: "fake-1", alg: "EdDSA", use: "sig" }
|
|
30
|
+
}));
|
|
31
|
+
const refreshTokens = /* @__PURE__ */ new Map();
|
|
32
|
+
const accessTokens = /* @__PURE__ */ new Map();
|
|
33
|
+
const failures = [];
|
|
34
|
+
const requests = [];
|
|
35
|
+
const seen = { refreshes: 0, appTokens: 0, reused: 0 };
|
|
36
|
+
const targetOf = (path) => path === "/api/auth/oauth2/token" ? "token" : path === "/.well-known/openid-configuration" ? "discovery" : path === "/.well-known/sovereign-instance" ? "instance" : "api";
|
|
37
|
+
const takeFailure = (target) => {
|
|
38
|
+
const index = failures.findIndex((f) => f.target === "any" || f.target === target);
|
|
39
|
+
const found = failures[index];
|
|
40
|
+
if (!found) return void 0;
|
|
41
|
+
found.times -= 1;
|
|
42
|
+
if (found.times <= 0) failures.splice(index, 1);
|
|
43
|
+
return found.failure;
|
|
44
|
+
};
|
|
45
|
+
const down = () => {
|
|
46
|
+
throw new TypeError("fetch failed", {
|
|
47
|
+
cause: Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" })
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
const hang = (signal) => new Promise((_resolve, reject) => {
|
|
51
|
+
const stop = () => reject(signal?.reason ?? new DOMException("The operation was aborted.", "TimeoutError"));
|
|
52
|
+
if (!signal) return;
|
|
53
|
+
if (signal.aborted) stop();
|
|
54
|
+
else signal.addEventListener("abort", stop, { once: true });
|
|
55
|
+
});
|
|
56
|
+
const issueAccess = (access) => {
|
|
57
|
+
const token = random("at");
|
|
58
|
+
accessTokens.set(token, { ...access, expiresAt: now() + lifetime * 1e3 });
|
|
59
|
+
return token;
|
|
60
|
+
};
|
|
61
|
+
const tokenEndpoint = async (request, failure, signal) => {
|
|
62
|
+
const given = request.headers.get("authorization") ?? "";
|
|
63
|
+
const decoded = given.startsWith("Basic ") ? atob(given.slice(6)) : "";
|
|
64
|
+
const colon = decoded.indexOf(":");
|
|
65
|
+
const decode = (part) => {
|
|
66
|
+
try {
|
|
67
|
+
return decodeURIComponent(part.replace(/\+/g, " "));
|
|
68
|
+
} catch {
|
|
69
|
+
return part;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
if (colon < 0 || decode(decoded.slice(0, colon)) !== credentials.clientId || decode(decoded.slice(colon + 1)) !== credentials.clientSecret) {
|
|
73
|
+
return json({ error: "invalid_client", error_description: "the app is not recognised" }, 401);
|
|
74
|
+
}
|
|
75
|
+
const form = new URLSearchParams(await request.text());
|
|
76
|
+
const grant = form.get("grant_type");
|
|
77
|
+
let answer;
|
|
78
|
+
if (grant === "authorization_code") {
|
|
79
|
+
const record = codes.get(form.get("code") ?? "");
|
|
80
|
+
const verifier = form.get("code_verifier") ?? "";
|
|
81
|
+
if (!record || record.used || form.get("redirect_uri") !== record.redirectUri || await challengeOf(verifier) !== record.challenge) {
|
|
82
|
+
if (record) record.used = true;
|
|
83
|
+
answer = json({ error: "invalid_grant", error_description: "the code is not valid" }, 400);
|
|
84
|
+
} else {
|
|
85
|
+
record.used = true;
|
|
86
|
+
const person = people.get(record.sub);
|
|
87
|
+
const issued = Math.floor(now() / 1e3);
|
|
88
|
+
const idToken = await new SignJWT({
|
|
89
|
+
...record.nonce ? { nonce: record.nonce } : {},
|
|
90
|
+
sid: record.sid,
|
|
91
|
+
name: person?.name ?? record.sub,
|
|
92
|
+
...record.scope.split(" ").includes("roles") ? { roles: record.roles } : {}
|
|
93
|
+
}).setProtectedHeader({ alg: "EdDSA", kid: "fake-1" }).setIssuer(url).setAudience(credentials.clientId).setSubject(record.sub).setIssuedAt(issued).setExpirationTime(issued + 3600).sign((await keys()).privateKey);
|
|
94
|
+
let refresh;
|
|
95
|
+
if (record.scope.split(" ").includes("offline_access")) {
|
|
96
|
+
refresh = random("rt");
|
|
97
|
+
refreshTokens.set(refresh, { sub: record.sub, used: false });
|
|
98
|
+
}
|
|
99
|
+
answer = json({
|
|
100
|
+
token_type: "Bearer",
|
|
101
|
+
access_token: issueAccess({ kind: "person", sub: record.sub }),
|
|
102
|
+
id_token: idToken,
|
|
103
|
+
...refresh ? { refresh_token: refresh } : {},
|
|
104
|
+
expires_in: lifetime,
|
|
105
|
+
scope: record.scope
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
} else if (grant === "refresh_token") {
|
|
109
|
+
const presented = form.get("refresh_token") ?? "";
|
|
110
|
+
const known = refreshTokens.get(presented);
|
|
111
|
+
if (!known) {
|
|
112
|
+
answer = json({ error: "invalid_grant", error_description: "the token is not valid" }, 400);
|
|
113
|
+
} else if (known.used) {
|
|
114
|
+
seen.reused += 1;
|
|
115
|
+
for (const [token, other] of refreshTokens)
|
|
116
|
+
if (other.sub === known.sub) refreshTokens.delete(token);
|
|
117
|
+
for (const [token, other] of accessTokens)
|
|
118
|
+
if (other.kind === "person" && other.sub === known.sub) accessTokens.delete(token);
|
|
119
|
+
answer = json(
|
|
120
|
+
{ error: "invalid_grant", error_description: "the token was already used" },
|
|
121
|
+
400
|
|
122
|
+
);
|
|
123
|
+
} else {
|
|
124
|
+
known.used = true;
|
|
125
|
+
seen.refreshes += 1;
|
|
126
|
+
const next = random("rt");
|
|
127
|
+
refreshTokens.set(next, { sub: known.sub, used: false });
|
|
128
|
+
answer = json({
|
|
129
|
+
token_type: "Bearer",
|
|
130
|
+
access_token: issueAccess({ kind: "person", sub: known.sub }),
|
|
131
|
+
refresh_token: next,
|
|
132
|
+
expires_in: lifetime,
|
|
133
|
+
scope: "directory notifications"
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
} else if (grant === "client_credentials" && appTokens) {
|
|
137
|
+
seen.appTokens += 1;
|
|
138
|
+
answer = json({
|
|
139
|
+
token_type: "Bearer",
|
|
140
|
+
access_token: issueAccess({ kind: "app" }),
|
|
141
|
+
expires_in: lifetime
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
answer = json(
|
|
145
|
+
{ error: "unsupported_grant_type", error_description: "the grant is not offered" },
|
|
146
|
+
400
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (failure?.kind === "lost-answer") return hang(signal);
|
|
150
|
+
return answer;
|
|
151
|
+
};
|
|
152
|
+
const bearer = (request) => {
|
|
153
|
+
const header = request.headers.get("authorization") ?? "";
|
|
154
|
+
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
|
|
155
|
+
const found = accessTokens.get(token);
|
|
156
|
+
if (!found || found.expiresAt <= now()) {
|
|
157
|
+
return problem(401, "invalid_token", "The token is missing, expired or revoked.", {
|
|
158
|
+
"www-authenticate": 'Bearer error="invalid_token"'
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return found;
|
|
162
|
+
};
|
|
163
|
+
const user = (person) => ({
|
|
164
|
+
sub: person.sub,
|
|
165
|
+
name: person.name,
|
|
166
|
+
picture: person.picture ?? null
|
|
167
|
+
});
|
|
168
|
+
const sent = /* @__PURE__ */ new Map();
|
|
169
|
+
const notificationJson = (n) => ({
|
|
170
|
+
id: n.id,
|
|
171
|
+
title: n.title,
|
|
172
|
+
body: n.body ?? null,
|
|
173
|
+
url: n.url ?? null,
|
|
174
|
+
category: n.category ?? "info",
|
|
175
|
+
createdAt: new Date(n.createdAt).toISOString(),
|
|
176
|
+
readAt: n.readAt === null ? null : new Date(n.readAt).toISOString(),
|
|
177
|
+
expiresAt: new Date(n.createdAt + 30 * 864e5).toISOString()
|
|
178
|
+
});
|
|
179
|
+
const emails = /* @__PURE__ */ new Map();
|
|
180
|
+
const emailApi = async (request) => {
|
|
181
|
+
const who = bearer(request);
|
|
182
|
+
if (who instanceof Response) return who;
|
|
183
|
+
if (who.kind !== "app" || request.method !== "POST") {
|
|
184
|
+
return problem(403, "insufficient_scope", "Only an app acting for itself may send email.");
|
|
185
|
+
}
|
|
186
|
+
const body = await request.json().catch(() => void 0);
|
|
187
|
+
const text = (value, max) => typeof value === "string" && value.trim() !== "" && value.length <= max;
|
|
188
|
+
if (!body || !text(body.to, 255) || !text(body.subject, 150) || !text(body.body, 5e3) || body.link !== void 0 && typeof body.link !== "string" || body.key !== void 0 && !text(body.key, 100)) {
|
|
189
|
+
return problem(400, "invalid_request", "to, subject and body are required text.");
|
|
190
|
+
}
|
|
191
|
+
const to = body.to;
|
|
192
|
+
const key = body.key;
|
|
193
|
+
const same = key === void 0 ? void 0 : [...emails.values()].find(
|
|
194
|
+
(email) => email.to === to && email.key === key && now() - email.sentAt < 864e5
|
|
195
|
+
);
|
|
196
|
+
if (same) return json({ id: same.id }, 202);
|
|
197
|
+
const id = random("m");
|
|
198
|
+
if (people.has(to) && people.get(to)?.active !== false) {
|
|
199
|
+
emails.set(id, {
|
|
200
|
+
id,
|
|
201
|
+
to,
|
|
202
|
+
subject: body.subject,
|
|
203
|
+
body: body.body,
|
|
204
|
+
...typeof body.link === "string" ? { link: body.link } : {},
|
|
205
|
+
...key === void 0 ? {} : { key },
|
|
206
|
+
sentAt: now()
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return json({ id }, 202);
|
|
210
|
+
};
|
|
211
|
+
const entries = /* @__PURE__ */ new Map();
|
|
212
|
+
const activityApi = async (request) => {
|
|
213
|
+
const who = bearer(request);
|
|
214
|
+
if (who instanceof Response) return who;
|
|
215
|
+
if (who.kind !== "app" || request.method !== "POST") {
|
|
216
|
+
return problem(
|
|
217
|
+
403,
|
|
218
|
+
"insufficient_scope",
|
|
219
|
+
"Only an app acting for itself may record activity."
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const body = await request.json().catch(() => void 0);
|
|
223
|
+
const text = (value, max) => typeof value === "string" && value.trim() !== "" && value.length <= max;
|
|
224
|
+
const when = typeof body?.at === "string" ? Date.parse(body.at) : now();
|
|
225
|
+
if (!body || !text(body.to, 255) || !text(body.summary, 200) || body.link !== void 0 && typeof body.link !== "string" || body.key !== void 0 && !text(body.key, 100) || !Number.isFinite(when)) {
|
|
226
|
+
return problem(400, "invalid_request", "to and summary are required text.");
|
|
227
|
+
}
|
|
228
|
+
const to = body.to;
|
|
229
|
+
const key = body.key;
|
|
230
|
+
const same = key === void 0 ? void 0 : [...entries.values()].find(
|
|
231
|
+
(entry) => entry.to === to && entry.key === key && now() - entry.recordedAt < 864e5
|
|
232
|
+
);
|
|
233
|
+
if (same) return json({ id: same.id }, 202);
|
|
234
|
+
const id = random("a");
|
|
235
|
+
if (people.has(to) && people.get(to)?.active !== false) {
|
|
236
|
+
entries.set(id, {
|
|
237
|
+
id,
|
|
238
|
+
to,
|
|
239
|
+
summary: body.summary,
|
|
240
|
+
...typeof body.link === "string" ? { link: body.link } : {},
|
|
241
|
+
at: when,
|
|
242
|
+
...key === void 0 ? {} : { key },
|
|
243
|
+
recordedAt: now()
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return json({ id }, 202);
|
|
247
|
+
};
|
|
248
|
+
const notificationsApi = async (request, path) => {
|
|
249
|
+
const who = bearer(request);
|
|
250
|
+
if (who instanceof Response) return who;
|
|
251
|
+
const sendPath = path.pathname === "/api/v1/notifications" && request.method === "POST";
|
|
252
|
+
if (sendPath) {
|
|
253
|
+
if (who.kind !== "app") {
|
|
254
|
+
return problem(403, "insufficient_scope", "Only an app acting for itself may send.");
|
|
255
|
+
}
|
|
256
|
+
const body = await request.json().catch(() => void 0);
|
|
257
|
+
if (!body || typeof body.to !== "string" || typeof body.title !== "string" || body.title === "") {
|
|
258
|
+
return problem(400, "invalid_request", "to and title are required.");
|
|
259
|
+
}
|
|
260
|
+
const id = random("n");
|
|
261
|
+
if (people.get(body.to)?.active !== false && people.has(body.to)) {
|
|
262
|
+
sent.set(id, {
|
|
263
|
+
id,
|
|
264
|
+
to: body.to,
|
|
265
|
+
title: body.title,
|
|
266
|
+
...typeof body.body === "string" ? { body: body.body } : {},
|
|
267
|
+
...typeof body.url === "string" ? { url: body.url } : {},
|
|
268
|
+
...typeof body.category === "string" ? { category: body.category } : {},
|
|
269
|
+
createdAt: now(),
|
|
270
|
+
readAt: null,
|
|
271
|
+
dismissed: false
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return json({ id }, 202);
|
|
275
|
+
}
|
|
276
|
+
if (who.kind !== "person") {
|
|
277
|
+
return problem(
|
|
278
|
+
403,
|
|
279
|
+
"insufficient_scope",
|
|
280
|
+
"The token does not carry the notifications scope."
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
const mine = () => [...sent.values()].filter((n) => n.to === who.sub && !n.dismissed);
|
|
284
|
+
if (path.pathname === "/api/v1/notifications" && request.method === "GET") {
|
|
285
|
+
const unread = path.searchParams.get("unread") === "true";
|
|
286
|
+
const limit = Number(path.searchParams.get("limit") ?? 50);
|
|
287
|
+
const found = mine().filter((n) => !unread || n.readAt === null).sort((a, b) => b.createdAt - a.createdAt).slice(0, Number.isInteger(limit) && limit > 0 ? limit : 50);
|
|
288
|
+
return json({ notifications: found.map(notificationJson) });
|
|
289
|
+
}
|
|
290
|
+
if (request.method === "POST" && path.pathname === "/api/v1/notifications/read-all") {
|
|
291
|
+
const all = mine().filter((n) => n.readAt === null);
|
|
292
|
+
for (const n of all) n.readAt = now();
|
|
293
|
+
return json({ count: all.length });
|
|
294
|
+
}
|
|
295
|
+
if (request.method === "POST" && path.pathname === "/api/v1/notifications/dismiss-all") {
|
|
296
|
+
const all = mine();
|
|
297
|
+
for (const n of all) n.dismissed = true;
|
|
298
|
+
return json({ count: all.length });
|
|
299
|
+
}
|
|
300
|
+
const one = /^\/api\/v1\/notifications\/([^/]+)\/(read|dismiss)$/.exec(path.pathname);
|
|
301
|
+
if (one && request.method === "POST") {
|
|
302
|
+
const found = mine().find((n) => n.id === one[1]);
|
|
303
|
+
if (!found) return problem(404, "not_found", "There is no such notification.");
|
|
304
|
+
if (one[2] === "read") found.readAt ??= now();
|
|
305
|
+
else found.dismissed = true;
|
|
306
|
+
return json(notificationJson(found));
|
|
307
|
+
}
|
|
308
|
+
return problem(404, "not_found", "There is no such route.");
|
|
309
|
+
};
|
|
310
|
+
const directory = async (request, path) => {
|
|
311
|
+
const who = bearer(request);
|
|
312
|
+
if (who instanceof Response) return who;
|
|
313
|
+
if (who.kind !== "person") {
|
|
314
|
+
return problem(403, "insufficient_scope", "The token does not carry the directory scope.");
|
|
315
|
+
}
|
|
316
|
+
if (path.pathname === "/api/v1/directory/users" && request.method === "GET") {
|
|
317
|
+
const q = (path.searchParams.get("q") ?? "").trim().toLowerCase();
|
|
318
|
+
if (q === "" || q.length > 100)
|
|
319
|
+
return problem(400, "invalid_request", "q is 1 to 100 characters.");
|
|
320
|
+
const limit = Number(path.searchParams.get("limit") ?? 10);
|
|
321
|
+
const found = [...people.values()].filter((p) => p.active !== false && p.name.toLowerCase().includes(q)).slice(0, Number.isInteger(limit) && limit > 0 ? limit : 10);
|
|
322
|
+
return json({ users: found.map(user) });
|
|
323
|
+
}
|
|
324
|
+
if (path.pathname === "/api/v1/directory/users/resolve" && request.method === "POST") {
|
|
325
|
+
const body = await request.json().catch(() => void 0);
|
|
326
|
+
const subs = body?.subs;
|
|
327
|
+
if (!Array.isArray(subs) || subs.length < 1 || subs.length > 100 || subs.some((s) => typeof s !== "string" || s === "")) {
|
|
328
|
+
return problem(400, "invalid_request", "subs is 1 to 100 identifiers.");
|
|
329
|
+
}
|
|
330
|
+
const users = [];
|
|
331
|
+
for (const sub of subs) {
|
|
332
|
+
const person = people.get(sub);
|
|
333
|
+
if (person) users.push({ ...user(person), active: person.active !== false });
|
|
334
|
+
}
|
|
335
|
+
return json({ users });
|
|
336
|
+
}
|
|
337
|
+
if (path.pathname === "/api/v1/directory/users/by-email" && request.method === "GET") {
|
|
338
|
+
const email = path.searchParams.get("email") ?? "";
|
|
339
|
+
if (!email.includes("@"))
|
|
340
|
+
return problem(400, "invalid_request", "email is a whole email address.");
|
|
341
|
+
return problem(404, "not_found", "There is no such person.");
|
|
342
|
+
}
|
|
343
|
+
return problem(404, "not_found", "There is no such route.");
|
|
344
|
+
};
|
|
345
|
+
const handle = async (request, signal) => {
|
|
346
|
+
const path = new URL(request.url);
|
|
347
|
+
if (path.origin !== url) down();
|
|
348
|
+
const target = targetOf(path.pathname);
|
|
349
|
+
const failure = takeFailure(target);
|
|
350
|
+
if (failure?.kind === "down") down();
|
|
351
|
+
if (failure?.kind === "timeout") return hang(signal);
|
|
352
|
+
if (failure?.kind === "status") {
|
|
353
|
+
return problem(
|
|
354
|
+
failure.status,
|
|
355
|
+
failure.status === 429 ? "rate_limited" : "invalid_request",
|
|
356
|
+
"The fake core was told to fail.",
|
|
357
|
+
{
|
|
358
|
+
// Marks an answer the fake was told to give, which the contract does not have to describe.
|
|
359
|
+
"x-fake-failure": "true",
|
|
360
|
+
...failure.retryAfter === void 0 ? {} : { "retry-after": String(failure.retryAfter) }
|
|
361
|
+
}
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
if (path.pathname === "/.well-known/openid-configuration") {
|
|
365
|
+
return json({
|
|
366
|
+
issuer: url,
|
|
367
|
+
token_endpoint: `${url}/api/auth/oauth2/token`,
|
|
368
|
+
authorization_endpoint: `${url}/api/auth/oauth2/authorize`,
|
|
369
|
+
end_session_endpoint: `${url}/api/auth/oauth2/end-session`,
|
|
370
|
+
jwks_uri: `${url}/api/auth/jwks`,
|
|
371
|
+
response_types_supported: ["code"],
|
|
372
|
+
subject_types_supported: ["public"],
|
|
373
|
+
id_token_signing_alg_values_supported: ["EdDSA"],
|
|
374
|
+
code_challenge_methods_supported: ["S256"],
|
|
375
|
+
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
|
|
376
|
+
authorization_response_iss_parameter_supported: true
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (path.pathname === "/api/auth/jwks") {
|
|
380
|
+
return json({ keys: [(await keys()).publicJwk] });
|
|
381
|
+
}
|
|
382
|
+
if (path.pathname === "/.well-known/sovereign-instance") {
|
|
383
|
+
const document = {
|
|
384
|
+
name: options.instance?.name ?? "Fake instance",
|
|
385
|
+
url,
|
|
386
|
+
logo: { light: null, dark: null },
|
|
387
|
+
favicon: null
|
|
388
|
+
};
|
|
389
|
+
if (api) document.api = api;
|
|
390
|
+
return json(document, 200, { "access-control-allow-origin": "*" });
|
|
391
|
+
}
|
|
392
|
+
if (path.pathname === "/api/auth/oauth2/token" && request.method === "POST") {
|
|
393
|
+
return tokenEndpoint(request, failure, signal);
|
|
394
|
+
}
|
|
395
|
+
if (path.pathname === "/api/v1/activity") {
|
|
396
|
+
const answer = await activityApi(request);
|
|
397
|
+
return failure?.kind === "lost-answer" ? hang(signal) : answer;
|
|
398
|
+
}
|
|
399
|
+
if (path.pathname === "/api/v1/email") {
|
|
400
|
+
const answer = await emailApi(request);
|
|
401
|
+
return failure?.kind === "lost-answer" ? hang(signal) : answer;
|
|
402
|
+
}
|
|
403
|
+
if (path.pathname.startsWith("/api/v1/notifications")) {
|
|
404
|
+
const answer = await notificationsApi(request, path);
|
|
405
|
+
return failure?.kind === "lost-answer" ? hang(signal) : answer;
|
|
406
|
+
}
|
|
407
|
+
if (path.pathname.startsWith("/api/v1/")) {
|
|
408
|
+
const answer = await directory(request, path);
|
|
409
|
+
return failure?.kind === "lost-answer" ? hang(signal) : answer;
|
|
410
|
+
}
|
|
411
|
+
return problem(404, "not_found", "There is no such route.");
|
|
412
|
+
};
|
|
413
|
+
const fakeFetch = (async (input, init) => {
|
|
414
|
+
const request = new Request(input, init);
|
|
415
|
+
const signal = init?.signal ?? (input instanceof Request ? input.signal : void 0);
|
|
416
|
+
const entry = {
|
|
417
|
+
method: request.method,
|
|
418
|
+
path: new URL(request.url).pathname,
|
|
419
|
+
status: 0
|
|
420
|
+
};
|
|
421
|
+
requests.push(entry);
|
|
422
|
+
const answer = await handle(request, signal);
|
|
423
|
+
entry.status = answer.status;
|
|
424
|
+
return answer;
|
|
425
|
+
});
|
|
426
|
+
const notice = async (typ, event, who, wrong) => {
|
|
427
|
+
const issued = Math.floor(now() / 1e3);
|
|
428
|
+
const claims = { events: { [event]: {} } };
|
|
429
|
+
if (who.sub && wrong !== "no-subject") claims.sub = who.sub;
|
|
430
|
+
if (who.sid && wrong !== "no-subject") claims.sid = who.sid;
|
|
431
|
+
if (wrong === "event") claims.events = {};
|
|
432
|
+
if (wrong === "nonce") claims.nonce = "a-nonce";
|
|
433
|
+
let jwt = new SignJWT(claims).setProtectedHeader({ alg: "EdDSA", kid: "fake-1", typ: wrong === "type" ? "JWT" : typ }).setIssuer(wrong === "issuer" ? "https://elsewhere.test" : url).setAudience(wrong === "audience" ? "another-app" : credentials.clientId).setIssuedAt(wrong === "future" ? issued + 3600 : issued).setExpirationTime(wrong === "expired" ? issued - 10 : issued + 120);
|
|
434
|
+
if (wrong !== "no-jti") jwt = jwt.setJti(random("jti"));
|
|
435
|
+
if (wrong === "signature") {
|
|
436
|
+
const other = await generateKeyPair("EdDSA");
|
|
437
|
+
return jwt.sign(other.privateKey);
|
|
438
|
+
}
|
|
439
|
+
return jwt.sign((await keys()).privateKey);
|
|
440
|
+
};
|
|
441
|
+
return {
|
|
442
|
+
url,
|
|
443
|
+
credentials,
|
|
444
|
+
fetch: fakeFetch,
|
|
445
|
+
addPerson(person) {
|
|
446
|
+
people.set(person.sub, {
|
|
447
|
+
sub: person.sub,
|
|
448
|
+
name: person.name ?? person.sub,
|
|
449
|
+
picture: person.picture ?? null,
|
|
450
|
+
active: person.active ?? true
|
|
451
|
+
});
|
|
452
|
+
},
|
|
453
|
+
signIn(sub) {
|
|
454
|
+
if (!people.has(sub)) people.set(sub, { sub, name: sub, picture: null, active: true });
|
|
455
|
+
const token = random("rt");
|
|
456
|
+
refreshTokens.set(token, { sub, used: false });
|
|
457
|
+
return token;
|
|
458
|
+
},
|
|
459
|
+
fail(failure, { target = "any", times = Number.POSITIVE_INFINITY } = {}) {
|
|
460
|
+
failures.push({ failure, target, times });
|
|
461
|
+
},
|
|
462
|
+
heal() {
|
|
463
|
+
failures.length = 0;
|
|
464
|
+
},
|
|
465
|
+
authorize(authorizationUrl, sub, options2 = {}) {
|
|
466
|
+
const request = new URL(authorizationUrl);
|
|
467
|
+
const given = request.searchParams;
|
|
468
|
+
const redirectUri = given.get("redirect_uri") ?? "";
|
|
469
|
+
const scope = given.get("scope") ?? "";
|
|
470
|
+
const problems = [
|
|
471
|
+
request.origin + request.pathname !== `${url}/api/auth/oauth2/authorize` && "wrong endpoint",
|
|
472
|
+
given.get("client_id") !== credentials.clientId && "unknown client_id",
|
|
473
|
+
given.get("response_type") !== "code" && "response_type is not code",
|
|
474
|
+
given.get("code_challenge_method") !== "S256" && "PKCE S256 is required",
|
|
475
|
+
!given.get("code_challenge") && "a code_challenge is required",
|
|
476
|
+
!redirectUri && "a redirect_uri is required",
|
|
477
|
+
!scope.split(" ").includes("openid") && "the openid scope is required"
|
|
478
|
+
].filter(Boolean);
|
|
479
|
+
if (problems.length > 0) {
|
|
480
|
+
throw new Error(`the fake core refuses this authorization request: ${problems.join(", ")}`);
|
|
481
|
+
}
|
|
482
|
+
const code = random("code");
|
|
483
|
+
const sid = random("sid");
|
|
484
|
+
codes.set(code, {
|
|
485
|
+
sub,
|
|
486
|
+
sid,
|
|
487
|
+
nonce: given.get("nonce"),
|
|
488
|
+
challenge: given.get("code_challenge") ?? "",
|
|
489
|
+
redirectUri,
|
|
490
|
+
scope,
|
|
491
|
+
roles: options2.roles ?? [],
|
|
492
|
+
used: false
|
|
493
|
+
});
|
|
494
|
+
sessionOf.set(sub, sid);
|
|
495
|
+
if (!people.has(sub)) people.set(sub, { sub, name: sub, picture: null, active: true });
|
|
496
|
+
const back = new URL(redirectUri);
|
|
497
|
+
back.searchParams.set("code", code);
|
|
498
|
+
const state = given.get("state");
|
|
499
|
+
if (state) back.searchParams.set("state", state);
|
|
500
|
+
back.searchParams.set("iss", url);
|
|
501
|
+
return back.toString();
|
|
502
|
+
},
|
|
503
|
+
sessionIdOf: (sub) => sessionOf.get(sub),
|
|
504
|
+
async logoutToken(who, { wrong } = {}) {
|
|
505
|
+
return notice("logout+jwt", "http://schemas.openid.net/event/backchannel-logout", who, wrong);
|
|
506
|
+
},
|
|
507
|
+
async accountDeletedToken(who, { wrong } = {}) {
|
|
508
|
+
return notice("account-deleted+jwt", "urn:sovereignfs:event:account-deleted", who, wrong);
|
|
509
|
+
},
|
|
510
|
+
get notificationsSent() {
|
|
511
|
+
return [...sent.values()];
|
|
512
|
+
},
|
|
513
|
+
get activityRecorded() {
|
|
514
|
+
return [...entries.values()];
|
|
515
|
+
},
|
|
516
|
+
get emailsSent() {
|
|
517
|
+
return [...emails.values()];
|
|
518
|
+
},
|
|
519
|
+
revoke(sub) {
|
|
520
|
+
for (const [token, other] of refreshTokens)
|
|
521
|
+
if (other.sub === sub) refreshTokens.delete(token);
|
|
522
|
+
for (const [token, other] of accessTokens)
|
|
523
|
+
if (other.kind === "person" && other.sub === sub) accessTokens.delete(token);
|
|
524
|
+
},
|
|
525
|
+
expireAccessTokens() {
|
|
526
|
+
accessTokens.clear();
|
|
527
|
+
},
|
|
528
|
+
get seen() {
|
|
529
|
+
return { requests: [...requests], ...seen };
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
function memorySessionStore(now = () => Date.now()) {
|
|
534
|
+
const records = /* @__PURE__ */ new Map();
|
|
535
|
+
return {
|
|
536
|
+
get(id) {
|
|
537
|
+
const found = records.get(id);
|
|
538
|
+
if (!found || found.expiresAt <= now()) return void 0;
|
|
539
|
+
return found.record;
|
|
540
|
+
},
|
|
541
|
+
set(id, record, expiresAt) {
|
|
542
|
+
records.set(id, { record, expiresAt });
|
|
543
|
+
},
|
|
544
|
+
delete(id) {
|
|
545
|
+
records.delete(id);
|
|
546
|
+
},
|
|
547
|
+
deleteMatching({ sub, sid }) {
|
|
548
|
+
for (const [id, { record }] of records) {
|
|
549
|
+
if (record.kind !== "session") continue;
|
|
550
|
+
if (sid !== void 0 && record.sid === sid || sid === void 0 && sub !== void 0 && record.sub === sub) {
|
|
551
|
+
records.delete(id);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
export {
|
|
558
|
+
createFakeCore,
|
|
559
|
+
memorySessionStore
|
|
560
|
+
};
|
package/package.json
CHANGED
|
@@ -1,50 +1,76 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sovereignfs/sdk",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
6
|
-
"license": "
|
|
7
|
-
"
|
|
8
|
-
"
|
|
5
|
+
"description": "How a JavaScript app's server talks to the Sovereign server it signs people in with: tokens, failure, version support.",
|
|
6
|
+
"license": "MPL-2.0",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"./src/browser.ts",
|
|
9
|
+
"./dist/browser.js"
|
|
10
|
+
],
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22.3"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/sovereignfs/sovereign.git",
|
|
17
|
+
"directory": "packages/sdk"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/sovereignfs/sovereign/tree/main/packages/sdk#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/sovereignfs/sovereign/issues"
|
|
22
|
+
},
|
|
23
|
+
"sovereign": {
|
|
24
|
+
"core": ">=0.46.0"
|
|
9
25
|
},
|
|
10
26
|
"exports": {
|
|
11
27
|
".": {
|
|
12
28
|
"types": "./dist/index.d.ts",
|
|
29
|
+
"browser": "./dist/browser.js",
|
|
13
30
|
"default": "./dist/index.js"
|
|
14
31
|
},
|
|
15
|
-
"./
|
|
16
|
-
"types": "./dist/
|
|
17
|
-
"default": "./dist/
|
|
18
|
-
},
|
|
19
|
-
"./e2ee-device": {
|
|
20
|
-
"types": "./dist/e2ee-device.d.ts",
|
|
21
|
-
"default": "./dist/e2ee-device.js"
|
|
22
|
-
},
|
|
23
|
-
"./e2ee-object": {
|
|
24
|
-
"types": "./dist/e2ee-object.d.ts",
|
|
25
|
-
"default": "./dist/e2ee-object.js"
|
|
32
|
+
"./next": {
|
|
33
|
+
"types": "./dist/next.d.ts",
|
|
34
|
+
"default": "./dist/next.js"
|
|
26
35
|
},
|
|
27
|
-
"./
|
|
28
|
-
"types": "./dist/
|
|
29
|
-
"default": "./dist/
|
|
36
|
+
"./testing": {
|
|
37
|
+
"types": "./dist/testing.d.ts",
|
|
38
|
+
"default": "./dist/testing.js"
|
|
30
39
|
}
|
|
31
40
|
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
32
44
|
"files": [
|
|
33
45
|
"dist",
|
|
34
|
-
"
|
|
46
|
+
"LICENSE",
|
|
47
|
+
"README.md"
|
|
35
48
|
],
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"jose": "^6.2.13",
|
|
51
|
+
"openid-client": "^6.8.8"
|
|
52
|
+
},
|
|
36
53
|
"peerDependencies": {
|
|
37
|
-
"next": ">=15
|
|
54
|
+
"next": ">=15"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"next": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
38
60
|
},
|
|
39
61
|
"devDependencies": {
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"@
|
|
62
|
+
"@sovereignfs/api-client": "0.1.0",
|
|
63
|
+
"@sovereignfs/config": "0.0.0",
|
|
64
|
+
"@sovereignfs/tsconfig": "0.0.0",
|
|
65
|
+
"@types/node": "^24.19.2",
|
|
66
|
+
"@types/react": "^19.3.0",
|
|
67
|
+
"next": "^16.4.3",
|
|
68
|
+
"react": "^19.3.1",
|
|
69
|
+
"tsup": "^9.0.0",
|
|
70
|
+
"typescript": "~6.0.3"
|
|
44
71
|
},
|
|
45
72
|
"scripts": {
|
|
46
73
|
"build": "tsup",
|
|
47
74
|
"typecheck": "tsc --noEmit"
|
|
48
|
-
}
|
|
49
|
-
"types": "./dist/index.d.ts"
|
|
75
|
+
}
|
|
50
76
|
}
|