@plurnk/plurnk-web 0.0.0-stage → 1.1.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/LICENSE +21 -0
- package/README.md +337 -2
- package/SPEC.md +869 -0
- package/ai.plurnk/.env.defaults +38 -0
- package/ai.plurnk/assets.json +193 -0
- package/ai.plurnk/creations.js +65 -0
- package/ai.plurnk/diagram-compiler.js +15 -0
- package/ai.plurnk/diagrams.js +130 -0
- package/ai.plurnk/directory-addresses.js +35 -0
- package/ai.plurnk/dist/assets/Temml-CCjoacWt.woff2 +0 -0
- package/ai.plurnk/dist/assets/index-B4w6iDKw.css +1 -0
- package/ai.plurnk/dist/assets/index-B4w6iDKw.css.br +0 -0
- package/ai.plurnk/dist/assets/index-B4w6iDKw.css.gz +0 -0
- package/ai.plurnk/dist/assets/index-CGRGblLU.js +2 -0
- package/ai.plurnk/dist/assets/index-CGRGblLU.js.br +0 -0
- package/ai.plurnk/dist/assets/index-CGRGblLU.js.gz +0 -0
- package/ai.plurnk/dist/assets/math-renderer-CZ_XtOMq.css +1 -0
- package/ai.plurnk/dist/assets/math-renderer-CZ_XtOMq.css.br +0 -0
- package/ai.plurnk/dist/assets/math-renderer-CZ_XtOMq.css.gz +0 -0
- package/ai.plurnk/dist/assets/math-renderer-CnlgS6FH.js +96 -0
- package/ai.plurnk/dist/assets/math-renderer-CnlgS6FH.js.br +0 -0
- package/ai.plurnk/dist/assets/math-renderer-CnlgS6FH.js.gz +0 -0
- package/ai.plurnk/dist/assets/overview-DEhW9HUG.js +1 -0
- package/ai.plurnk/dist/assets/overview-DEhW9HUG.js.br +0 -0
- package/ai.plurnk/dist/assets/overview-DEhW9HUG.js.gz +0 -0
- package/ai.plurnk/dist/assets/preload-helper-BjS5LQBu.js +1 -0
- package/ai.plurnk/dist/assets/preload-helper-BjS5LQBu.js.br +0 -0
- package/ai.plurnk/dist/assets/preload-helper-BjS5LQBu.js.gz +0 -0
- package/ai.plurnk/dist/assets/session-BcdGbgAa.js +4 -0
- package/ai.plurnk/dist/assets/session-BcdGbgAa.js.br +0 -0
- package/ai.plurnk/dist/assets/session-BcdGbgAa.js.gz +0 -0
- package/ai.plurnk/dist/assets/session-agent-DwY8S586.js +111 -0
- package/ai.plurnk/dist/assets/session-agent-DwY8S586.js.br +0 -0
- package/ai.plurnk/dist/assets/session-agent-DwY8S586.js.gz +0 -0
- package/ai.plurnk/dist/assets/src-Du01yeMu.js +1 -0
- package/ai.plurnk/dist/assets/src-Du01yeMu.js.br +0 -0
- package/ai.plurnk/dist/assets/src-J2qT9xpv.js +6 -0
- package/ai.plurnk/dist/assets/src-J2qT9xpv.js.br +0 -0
- package/ai.plurnk/dist/assets/src-J2qT9xpv.js.gz +0 -0
- package/ai.plurnk/dist/icons/APACHE-2.0.txt +202 -0
- package/ai.plurnk/dist/icons/APACHE-2.0.txt.br +0 -0
- package/ai.plurnk/dist/icons/APACHE-2.0.txt.gz +0 -0
- package/ai.plurnk/dist/icons/NOTO-NOTICE.txt +10 -0
- package/ai.plurnk/dist/icons/NOTO-NOTICE.txt.br +0 -0
- package/ai.plurnk/dist/icons/NOTO-NOTICE.txt.gz +0 -0
- package/ai.plurnk/dist/icons/plurnk-maskable.svg +64 -0
- package/ai.plurnk/dist/icons/plurnk-maskable.svg.br +0 -0
- package/ai.plurnk/dist/icons/plurnk-maskable.svg.gz +0 -0
- package/ai.plurnk/dist/icons/plurnk.svg +63 -0
- package/ai.plurnk/dist/icons/plurnk.svg.br +0 -0
- package/ai.plurnk/dist/icons/plurnk.svg.gz +0 -0
- package/ai.plurnk/dist/index.html +421 -0
- package/ai.plurnk/dist/manifest.webmanifest +27 -0
- package/ai.plurnk/dist/manifest.webmanifest.br +0 -0
- package/ai.plurnk/dist/manifest.webmanifest.gz +0 -0
- package/ai.plurnk/dist/vendor/CFWORKER-JSON-SCHEMA-LICENSE.txt +25 -0
- package/ai.plurnk/dist/vendor/CFWORKER-JSON-SCHEMA-LICENSE.txt.br +0 -0
- package/ai.plurnk/dist/vendor/CFWORKER-JSON-SCHEMA-LICENSE.txt.gz +0 -0
- package/ai.plurnk/dist/vendor/DEPENDENCIES-LICENSE.txt +571 -0
- package/ai.plurnk/dist/vendor/DEPENDENCIES-LICENSE.txt.br +0 -0
- package/ai.plurnk/dist/vendor/DEPENDENCIES-LICENSE.txt.gz +0 -0
- package/ai.plurnk/dist/vendor/MARKED-FOOTNOTE-LICENSE.txt +25 -0
- package/ai.plurnk/dist/vendor/MARKED-FOOTNOTE-LICENSE.txt.br +0 -0
- package/ai.plurnk/dist/vendor/MARKED-FOOTNOTE-LICENSE.txt.gz +0 -0
- package/ai.plurnk/dist/vendor/OTM-LICENSE.txt +21 -0
- package/ai.plurnk/dist/vendor/OTM-LICENSE.txt.br +0 -0
- package/ai.plurnk/dist/vendor/OTM-LICENSE.txt.gz +0 -0
- package/ai.plurnk/dist/vendor/PICO-LICENSE.txt +21 -0
- package/ai.plurnk/dist/vendor/PICO-LICENSE.txt.br +0 -0
- package/ai.plurnk/dist/vendor/PICO-LICENSE.txt.gz +0 -0
- package/ai.plurnk/documents.js +483 -0
- package/ai.plurnk/http.js +69 -0
- package/ai.plurnk/index-document.js +5 -0
- package/ai.plurnk/module.js +326 -0
- package/ai.plurnk/session.js +319 -0
- package/ai.plurnk/static-assets.js +40 -0
- package/package.json +92 -5
- package/plugin.json +13 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { MIMEType } from "node:util";
|
|
3
|
+
import { ConfigurationError, Knob } from "@plurnk/plurnk-meta";
|
|
4
|
+
import { Problems } from "@plurnk/plurnk-contracts";
|
|
5
|
+
import indexDocument from "./index-document.js";
|
|
6
|
+
import { directoryPath, parseDirectory } from "./directory-addresses.js";
|
|
7
|
+
import Documents from "./documents.js";
|
|
8
|
+
import Session, { CALLBACK, CLIENT } from "./session.js";
|
|
9
|
+
import { ownNavigation, privateHeaders, redirect, refusal, RequestError, sameOriginFetch } from "./http.js";
|
|
10
|
+
import { readAsset, sendAsset } from "./static-assets.js";
|
|
11
|
+
import Creations from "./creations.js";
|
|
12
|
+
import Diagrams from "./diagrams.js";
|
|
13
|
+
|
|
14
|
+
/** @typedef {{ credentials?: Pick<import("@plurnk/plurnk-contracts/port").ApplicationPort["credentials"], "registerClient"> | null }} SetupPort */
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {{
|
|
17
|
+
* credentials?: (import("./session.js").SessionCredentials & Pick<import("@plurnk/plurnk-contracts/port").ApplicationPort["credentials"], "face">) | null,
|
|
18
|
+
* registerHttpRoute: import("@plurnk/plurnk-contracts/port").ApplicationPort["registerHttpRoute"],
|
|
19
|
+
* listWorkspaces: () => Promise<readonly import("./documents.js").DirectoryWorkspace[]>,
|
|
20
|
+
* listSessions: (workspaceId: number) => Promise<readonly import("./documents.js").DirectorySession[]>,
|
|
21
|
+
* readSessionOverview?: import("@plurnk/plurnk-contracts/port").ApplicationPort["readSessionOverview"],
|
|
22
|
+
* createWorkspace: (args: { name?: string, projectRoot: string | null }) => Promise<string | { workspaceName: string, projectRoot: string | null }>,
|
|
23
|
+
* renameWorkspace: (workspaceId: number, name: string) => Promise<{ name: string }>,
|
|
24
|
+
* createSession: (args: { workspaceId: number, name?: string }) => Promise<{ sessionName: string }>,
|
|
25
|
+
* setWorkspaceArchived: (args: { workspaceId: number, archived: boolean }) => Promise<unknown>,
|
|
26
|
+
* setSessionArchived: (args: { workspaceId: number, sessionId: number, archived: boolean }) => Promise<unknown>,
|
|
27
|
+
* }} Port
|
|
28
|
+
*/
|
|
29
|
+
/** {§web-host} {§web-configuration} {§web-package} @returns {import("@plurnk/plurnk-modules").DaemonModule<SetupPort, Port>} */
|
|
30
|
+
export default function createModule() {
|
|
31
|
+
if (!Knob.flag("PLURNK_WEB_ENABLED")) return {};
|
|
32
|
+
/** @type {import("@plurnk/plurnk-modules").ContainedConfiguration[]} */
|
|
33
|
+
const contained = [];
|
|
34
|
+
/** @template T @param {() => T} read @returns {T | null} */
|
|
35
|
+
const contain = (read) => {
|
|
36
|
+
try { return read(); }
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (!(error instanceof ConfigurationError)) throw error;
|
|
39
|
+
contained.push({ key: error.key, message: error.message });
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const yolo = contain(() => Knob.flag("PLURNK_WEB_YOLO"));
|
|
44
|
+
const reasoningPreviewChars = contain(() => Knob.integer("PLURNK_WEB_REASONING_PREVIEW_CHARS", 1));
|
|
45
|
+
const limits = { ttl: contain(() => Knob.integer("PLURNK_WEB_LOGIN_TTL_SEC", 1)) };
|
|
46
|
+
const createTtl = contain(() => Knob.integer("PLURNK_WEB_CREATE_TTL_SEC", 1));
|
|
47
|
+
const createTokens = contain(() => Knob.integer("PLURNK_WEB_CREATE_TOKENS", 1));
|
|
48
|
+
const formBytes = contain(() => Knob.integer("PLURNK_WEB_FORM_BYTES", 1));
|
|
49
|
+
const creations = createTtl !== null && createTokens !== null ? new Creations({ ttl: createTtl, tokens: createTokens }) : null;
|
|
50
|
+
const diagrams = Diagrams.create(contain);
|
|
51
|
+
/** @type {{ clientId: string, clientSecret: string } | null} */
|
|
52
|
+
let registration = null;
|
|
53
|
+
/** @type {Session | null} */
|
|
54
|
+
let session = null;
|
|
55
|
+
let stopped = false;
|
|
56
|
+
/** @type {Set<Promise<void>>} */
|
|
57
|
+
const requests = new Set();
|
|
58
|
+
const stop = () => { stopped = true; };
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
mounts: ["/"],
|
|
62
|
+
contained: Object.freeze(contained),
|
|
63
|
+
async setup({ credentials }) {
|
|
64
|
+
if (!credentials) return;
|
|
65
|
+
// {§web-authorization-style} The same built stylesheet serves both document owners.
|
|
66
|
+
/** @type {import("./static-assets.js").AssetEntry[]} */
|
|
67
|
+
const inventory = JSON.parse(await readFile(new URL("assets.json", import.meta.url), "utf8"));
|
|
68
|
+
const root = inventory.find(([path]) => path === "/index.html");
|
|
69
|
+
if (!root) throw new Error("The web asset inventory must include index.html.");
|
|
70
|
+
const stylesheetPath = Documents.stylesheet((await readAsset(root[1], root[2], root[3])).body.toString("utf8"));
|
|
71
|
+
if (!inventory.some(([path, , type]) => path === stylesheetPath && type === "text/css")) throw new Error("The application stylesheet is absent from the web asset inventory.");
|
|
72
|
+
registration = credentials.registerClient({ id: CLIENT, redirectPaths: [CALLBACK], scopes: ["acp"], stylesheetPath });
|
|
73
|
+
},
|
|
74
|
+
async start(seam) {
|
|
75
|
+
const credentials = seam.credentials;
|
|
76
|
+
const face = credentials?.face() ?? null;
|
|
77
|
+
if (face !== null && credentials) {
|
|
78
|
+
if (registration === null) throw new Error("Web sign-in requires the daemon's credential authority and completed module setup.");
|
|
79
|
+
session = new Session(credentials, registration, face, limits);
|
|
80
|
+
}
|
|
81
|
+
// {§web-assets} Requests select entries, never filesystem paths.
|
|
82
|
+
/** @type {import("./static-assets.js").AssetEntry[]} */
|
|
83
|
+
const inventory = JSON.parse(await readFile(new URL("assets.json", import.meta.url), "utf8"));
|
|
84
|
+
const resources = new Map(await Promise.all(inventory.map(async ([pathname, file, mediaType, encoded]) => /** @type {const} */ ([
|
|
85
|
+
pathname,
|
|
86
|
+
await readAsset(file, mediaType, encoded),
|
|
87
|
+
]))));
|
|
88
|
+
|
|
89
|
+
const root = resources.get("/index.html");
|
|
90
|
+
if (root === undefined) throw new Error("The web asset inventory must include index.html.");
|
|
91
|
+
const documents = new Documents(root.body.toString("utf8"), yolo, reasoningPreviewChars);
|
|
92
|
+
|
|
93
|
+
/** @param {import("node:http").IncomingMessage} req @param {import("node:http").ServerResponse} res */
|
|
94
|
+
const serve = async (req, res) => {
|
|
95
|
+
const pathname = (req.url ?? "").split("?")[0];
|
|
96
|
+
const directory = parseDirectory(pathname);
|
|
97
|
+
const home = indexDocument(pathname) === "/index.html";
|
|
98
|
+
const account = indexDocument(pathname) === "/account/index.html";
|
|
99
|
+
const sessions = indexDocument(pathname) === "/account/sessions/index.html";
|
|
100
|
+
const revoke = /^\/account\/sessions\/([^/]+)\/revoke$/u.exec(pathname);
|
|
101
|
+
const others = pathname === "/account/sessions/revoke-others";
|
|
102
|
+
const token = pathname === "/auth/token";
|
|
103
|
+
const creation = pathname === "/workspaces/new";
|
|
104
|
+
const rename = pathname === "/workspaces/rename";
|
|
105
|
+
const sessionCreation = pathname === "/sessions/new";
|
|
106
|
+
const workspaceArchive = pathname === "/workspaces/archive";
|
|
107
|
+
const sessionArchive = pathname === "/sessions/archive";
|
|
108
|
+
const diagram = pathname === "/render/mermaid";
|
|
109
|
+
const auth = token || ["/auth/login", CALLBACK, "/auth/logout"].includes(pathname);
|
|
110
|
+
let admitted = false;
|
|
111
|
+
try {
|
|
112
|
+
const inventory = sessions ? session?.account(req) : null;
|
|
113
|
+
admitted = sessions ? inventory != null : session?.admitted(req) ?? false;
|
|
114
|
+
if (diagram) {
|
|
115
|
+
if (session !== null && !admitted) throw new RequestError(401, "Sign in to render diagrams.");
|
|
116
|
+
if (diagrams === null) throw new RequestError(503, "Diagram compilation is unavailable because its configuration is invalid.");
|
|
117
|
+
await diagrams.serve(req, res);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (token && session === null) throw new RequestError(404, "Access credentials are unavailable while authentication is disabled.");
|
|
121
|
+
if (auth && session !== null) {
|
|
122
|
+
const expected = token || pathname === "/auth/logout" ? "POST" : "GET";
|
|
123
|
+
if (req.method !== expected) {
|
|
124
|
+
res.setHeader("allow", expected);
|
|
125
|
+
throw new RequestError(405, "Method not allowed.");
|
|
126
|
+
}
|
|
127
|
+
if (pathname === "/auth/login") await session.login(req, res);
|
|
128
|
+
else if (pathname === CALLBACK) await session.callback(req, res);
|
|
129
|
+
else if (token) await session.token(req, res);
|
|
130
|
+
else session.logout(req, res);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (creation || rename || sessionCreation || workspaceArchive || sessionArchive) {
|
|
134
|
+
const actionName = creation ? "create a workspace" : rename ? "rename a workspace" : sessionCreation ? "create a session" : workspaceArchive ? "archive a workspace" : "archive a session";
|
|
135
|
+
if (req.method !== "POST") {
|
|
136
|
+
res.setHeader("allow", "POST");
|
|
137
|
+
throw new RequestError(405, `Use POST to ${actionName}.`);
|
|
138
|
+
}
|
|
139
|
+
if (session !== null && !admitted) throw new RequestError(401, `Sign in again before ${actionName}.`);
|
|
140
|
+
if (session !== null) session.sameOriginForm(req);
|
|
141
|
+
else {
|
|
142
|
+
const origin = req.headers.origin;
|
|
143
|
+
if (typeof origin !== "string") throw new RequestError(403, "Same-origin form required.");
|
|
144
|
+
let host;
|
|
145
|
+
try { host = new URL(origin).host; }
|
|
146
|
+
catch (cause) { throw new RequestError(403, "Same-origin form required.", { cause }); }
|
|
147
|
+
if (host !== req.headers.host) throw new RequestError(403, "Same-origin form required.");
|
|
148
|
+
sameOriginFetch(req);
|
|
149
|
+
}
|
|
150
|
+
let type;
|
|
151
|
+
try { type = new MIMEType(req.headers["content-type"] ?? ""); }
|
|
152
|
+
catch (cause) { throw new RequestError(415, "Use UTF-8 application/x-www-form-urlencoded.", { cause }); }
|
|
153
|
+
if (type.essence !== "application/x-www-form-urlencoded"
|
|
154
|
+
|| (type.params.has("charset") && type.params.get("charset")?.toLowerCase() !== "utf-8")) {
|
|
155
|
+
throw new RequestError(415, "Use UTF-8 application/x-www-form-urlencoded.");
|
|
156
|
+
}
|
|
157
|
+
if (formBytes === null) {
|
|
158
|
+
req.resume();
|
|
159
|
+
throw new RequestError(503, "Form submissions are unavailable because PLURNK_WEB_FORM_BYTES is invalid.");
|
|
160
|
+
}
|
|
161
|
+
if ((creation || sessionCreation) && creations === null) {
|
|
162
|
+
req.resume();
|
|
163
|
+
throw new RequestError(503, "Creation forms are unavailable because their configuration is invalid.");
|
|
164
|
+
}
|
|
165
|
+
let size = 0;
|
|
166
|
+
const chunks = [];
|
|
167
|
+
for await (const chunk of req.iterator({ destroyOnReturn: false })) {
|
|
168
|
+
size += chunk.byteLength;
|
|
169
|
+
if (size > formBytes) { req.resume(); throw new RequestError(413, "Form exceeds its bound."); }
|
|
170
|
+
chunks.push(chunk);
|
|
171
|
+
}
|
|
172
|
+
let fields;
|
|
173
|
+
try { fields = new URLSearchParams(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks))); }
|
|
174
|
+
catch (cause) { throw new RequestError(400, "Invalid form data.", { cause }); }
|
|
175
|
+
if (creation && creations !== null) {
|
|
176
|
+
const formToken = fields.getAll("token").length === 1 ? fields.get("token") : null;
|
|
177
|
+
if (!formToken) throw new RequestError(400, "Expected the form's workspace creation token.");
|
|
178
|
+
// An empty name is no name: the daemon names the workspace.
|
|
179
|
+
const name = fields.get("name") ?? "";
|
|
180
|
+
redirect(res, await creations.submit(formToken, async () => {
|
|
181
|
+
try { return await seam.createWorkspace({ ...(name === "" ? {} : { name }), projectRoot: null }); }
|
|
182
|
+
catch (cause) { throw refusal(cause, "The daemon could not create the workspace.", documents.retryCreation(name)); }
|
|
183
|
+
}));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (rename) {
|
|
187
|
+
const targetName = fields.get("workspace");
|
|
188
|
+
const newName = fields.get("name");
|
|
189
|
+
if (!targetName || !newName) throw new RequestError(400, "Expected current workspace and new name.");
|
|
190
|
+
const workspaces = await seam.listWorkspaces();
|
|
191
|
+
const ws = workspaces.find(({ name }) => name === targetName);
|
|
192
|
+
if (!ws) throw new RequestError(404, "Workspace not found.");
|
|
193
|
+
let renamed;
|
|
194
|
+
try { renamed = await seam.renameWorkspace(ws.id, newName); }
|
|
195
|
+
catch (cause) { throw refusal(cause, "The daemon could not rename the workspace.", documents.retryRename(ws.name, newName)); }
|
|
196
|
+
const path = directoryPath({ kind: "workspace", workspace: renamed.name });
|
|
197
|
+
if (path === null) throw new Error("The daemon returned a workspace name without a directory address.");
|
|
198
|
+
redirect(res, path);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (sessionCreation && creations !== null) {
|
|
202
|
+
const formToken = fields.getAll("token").length === 1 ? fields.get("token") : null;
|
|
203
|
+
const targetName = fields.get("workspace");
|
|
204
|
+
if (!formToken || !targetName) throw new RequestError(400, "Expected session creation token and workspace name.");
|
|
205
|
+
// An empty name is no name: the daemon names the session.
|
|
206
|
+
const sessionName = fields.get("name") ?? "";
|
|
207
|
+
const workspaces = await seam.listWorkspaces();
|
|
208
|
+
const ws = workspaces.find(({ name }) => name === targetName);
|
|
209
|
+
if (!ws) throw new RequestError(404, "Workspace not found.");
|
|
210
|
+
redirect(res, await creations.submit(formToken, async () => {
|
|
211
|
+
let createdSession;
|
|
212
|
+
try { createdSession = await seam.createSession({ workspaceId: ws.id, ...(sessionName === "" ? {} : { name: sessionName }) }); }
|
|
213
|
+
catch (cause) { throw refusal(cause, "The daemon could not create the session.", documents.retrySession(ws.name, sessionName)); }
|
|
214
|
+
// An internal fault, not a refusal: the session exists.
|
|
215
|
+
const path = directoryPath({ kind: "session", workspace: ws.name, session: createdSession.sessionName });
|
|
216
|
+
if (path === null) throw new Error("The daemon returned a session name without a directory address.");
|
|
217
|
+
return path;
|
|
218
|
+
}));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (workspaceArchive) {
|
|
222
|
+
const targetName = fields.get("workspace");
|
|
223
|
+
if (!targetName) throw new RequestError(400, "Expected workspace name.");
|
|
224
|
+
const workspaces = await seam.listWorkspaces();
|
|
225
|
+
const ws = workspaces.find(({ name }) => name === targetName);
|
|
226
|
+
if (!ws) throw new RequestError(404, "Workspace not found.");
|
|
227
|
+
try { await seam.setWorkspaceArchived({ workspaceId: ws.id, archived: true }); }
|
|
228
|
+
catch (cause) { throw refusal(cause, "The daemon could not archive the workspace."); }
|
|
229
|
+
redirect(res, "/~/");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (sessionArchive) {
|
|
233
|
+
const targetName = fields.get("workspace");
|
|
234
|
+
const targetSession = fields.get("session");
|
|
235
|
+
if (!targetName || !targetSession) throw new RequestError(400, "Expected workspace and session name.");
|
|
236
|
+
const workspaces = await seam.listWorkspaces();
|
|
237
|
+
const ws = workspaces.find(({ name }) => name === targetName);
|
|
238
|
+
if (!ws) throw new RequestError(404, "Workspace not found.");
|
|
239
|
+
const workspaceSessions = await seam.listSessions(ws.id);
|
|
240
|
+
const target = workspaceSessions.find(({ name }) => name === targetSession);
|
|
241
|
+
if (!target) throw new RequestError(404, "Session not found.");
|
|
242
|
+
try { await seam.setSessionArchived({ workspaceId: ws.id, sessionId: target.id, archived: true }); }
|
|
243
|
+
catch (cause) { throw refusal(cause, "The daemon could not archive the session."); }
|
|
244
|
+
const path = directoryPath({ kind: "workspace", workspace: ws.name });
|
|
245
|
+
if (path === null) throw new Error("The daemon returned a workspace name without a directory address.");
|
|
246
|
+
redirect(res, path);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (account || sessions || revoke !== null || others) {
|
|
251
|
+
if (session === null) throw new RequestError(404, "Account is unavailable while authentication is disabled.");
|
|
252
|
+
if (revoke !== null || others) {
|
|
253
|
+
if (req.method !== "POST") {
|
|
254
|
+
res.setHeader("allow", "POST");
|
|
255
|
+
throw new RequestError(405, "Method not allowed.");
|
|
256
|
+
}
|
|
257
|
+
if (others) session.revokeOthers(req, res);
|
|
258
|
+
else if (revoke !== null) {
|
|
259
|
+
let family;
|
|
260
|
+
try { family = decodeURIComponent(revoke[1]); }
|
|
261
|
+
catch (cause) { throw new RequestError(400, "Invalid session address.", { cause }); }
|
|
262
|
+
session.revoke(req, res, family);
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (directory !== null || home || account || sessions) {
|
|
268
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
269
|
+
res.setHeader("allow", "GET, HEAD");
|
|
270
|
+
throw new RequestError(405, "Method not allowed.");
|
|
271
|
+
}
|
|
272
|
+
if (!home && session !== null && !admitted) {
|
|
273
|
+
redirect(res, session.signIn(req, req.url ?? "/~/"));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const body = home ? await documents.home(admitted, session === null || admitted ? seam : undefined) : account ? documents.account() : inventory ? documents.sessions(inventory)
|
|
277
|
+
: directory === null ? null : await documents.read(directory, seam, admitted, ownNavigation(req));
|
|
278
|
+
if (body === null) throw new RequestError(404, "Workspace or session not found.");
|
|
279
|
+
res.writeHead(200, { ...privateHeaders, "content-type": "text/html; charset=utf-8", "content-length": body.byteLength });
|
|
280
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
284
|
+
res.setHeader("allow", "GET, HEAD");
|
|
285
|
+
throw new RequestError(405, "Use GET or HEAD for this resource.");
|
|
286
|
+
}
|
|
287
|
+
const resource = resources.get(indexDocument(pathname));
|
|
288
|
+
if (resource === undefined) throw new RequestError(404, "The requested page or resource does not exist.");
|
|
289
|
+
sendAsset(req, res, resource);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (!(error instanceof RequestError)) throw error;
|
|
292
|
+
const body = token || diagram ? Buffer.from(JSON.stringify(Problems.create("web", diagram ? "diagram-request-rejected" : "token-request-rejected", error.status, error.message)))
|
|
293
|
+
: documents.error(error.status, error.message, admitted, { recovery: error.recovery, retry: error.retry });
|
|
294
|
+
res.writeHead(error.status, { ...privateHeaders, "content-type": token || diagram ? "application/problem+json" : "text/html; charset=utf-8", "content-length": body.byteLength });
|
|
295
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
296
|
+
} finally {
|
|
297
|
+
if (diagram) req.resume();
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// {§web-route-settlement} Every admitted handler retains its host resources until settlement.
|
|
302
|
+
seam.registerHttpRoute("/", async (req, res) => {
|
|
303
|
+
if (stopped) {
|
|
304
|
+
const body = Buffer.from(JSON.stringify(Problems.create("web", "service-stopping", 503, "The PLURNK service is shutting down.")));
|
|
305
|
+
res.writeHead(503, { ...privateHeaders, "content-type": "application/problem+json", "content-length": body.byteLength });
|
|
306
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
307
|
+
req.resume();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const request = serve(req, res);
|
|
311
|
+
requests.add(request);
|
|
312
|
+
try { await request; }
|
|
313
|
+
finally { requests.delete(request); }
|
|
314
|
+
});
|
|
315
|
+
},
|
|
316
|
+
stop,
|
|
317
|
+
async close() {
|
|
318
|
+
stop();
|
|
319
|
+
const cleanup = new AsyncDisposableStack();
|
|
320
|
+
cleanup.defer(() => session?.close());
|
|
321
|
+
cleanup.defer(async () => { await Promise.allSettled(requests); });
|
|
322
|
+
cleanup.defer(() => diagrams?.close());
|
|
323
|
+
await cleanup.disposeAsync();
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
import * as oauth from "oauth4webapi";
|
|
3
|
+
import { cookie, redirect, RequestError, privateHeaders, sameOriginFetch } from "./http.js";
|
|
4
|
+
import { directoryPath, parseDirectory } from "./directory-addresses.js";
|
|
5
|
+
import indexDocument from "./index-document.js";
|
|
6
|
+
|
|
7
|
+
const SESSION = "plurnk_web_session";
|
|
8
|
+
// A sign-in transaction's cookie: this prefix and the transaction's state.
|
|
9
|
+
const TRANSACTION = "plurnk_web_login_";
|
|
10
|
+
// {§web-session} Under HTTPS the names carry the browser's prefixes that no sibling host can set: the session is host-only
|
|
11
|
+
// at `/`, and a transaction, scoped to `/auth`, is Secure.
|
|
12
|
+
const HOST_ONLY = "__Host-";
|
|
13
|
+
const SECURE_ONLY = "__Secure-";
|
|
14
|
+
export const CALLBACK = "/auth/callback";
|
|
15
|
+
export const CLIENT = "plurnk-web";
|
|
16
|
+
|
|
17
|
+
/** @typedef {{ access: string, refresh: string, claims: import("@plurnk/plurnk-contracts").CredentialClaims, refreshing: Promise<void> | null }} Grant */
|
|
18
|
+
/** @typedef {{ verifier: string, target: string, redirectUri: string, expires: number }} Transaction */
|
|
19
|
+
/** @typedef {import("@plurnk/plurnk-contracts").RequestHeaders & { url?: string }} SessionRequest */
|
|
20
|
+
/** @typedef {import("./http.js").HttpResponse} SessionResponse */
|
|
21
|
+
/** @typedef {Pick<import("@plurnk/plurnk-contracts").CredentialAuthority, "verify" | "issue" | "revokeFamily" | "sessions" | "onRevoked">} SessionCredentials */
|
|
22
|
+
|
|
23
|
+
/** {§web-session} A consumer of the daemon's authority, never an independent issuer or session inventory. */
|
|
24
|
+
export default class Session {
|
|
25
|
+
#credentials;
|
|
26
|
+
#client;
|
|
27
|
+
#authenticate;
|
|
28
|
+
#face;
|
|
29
|
+
/** @type {{ server: oauth.AuthorizationServer, options: { [oauth.allowInsecureRequests]: boolean } } | null} */
|
|
30
|
+
#authority = null;
|
|
31
|
+
#limits;
|
|
32
|
+
// {§web-session} Seals sign-in transactions; it lives and ends with this module instance.
|
|
33
|
+
#key = randomBytes(32);
|
|
34
|
+
/** @type {Map<string, Grant>} */
|
|
35
|
+
#grants = new Map();
|
|
36
|
+
#unsubscribe;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {SessionCredentials} credentials
|
|
40
|
+
* @param {{ clientId: string, clientSecret: string }} registration
|
|
41
|
+
* @param {import("@plurnk/plurnk-contracts").CredentialFace} face
|
|
42
|
+
* @param {{ ttl: number | null }} limits
|
|
43
|
+
*/
|
|
44
|
+
constructor(credentials, registration, face, limits) {
|
|
45
|
+
this.#credentials = credentials;
|
|
46
|
+
this.#client = { client_id: registration.clientId };
|
|
47
|
+
this.#authenticate = oauth.ClientSecretBasic(registration.clientSecret);
|
|
48
|
+
this.#face = face;
|
|
49
|
+
this.#limits = limits;
|
|
50
|
+
this.#unsubscribe = credentials.onRevoked((family) => this.#grants.delete(family));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** {§web-session} The daemon's issuer for this request: its configured origin, else the address the browser reached.
|
|
54
|
+
* @param {SessionRequest} req
|
|
55
|
+
*/
|
|
56
|
+
#issuer(req) { return this.#face.issuer(req); }
|
|
57
|
+
|
|
58
|
+
// {§web-session} This module's own calls go to the daemon's answer for no request, never to an address a
|
|
59
|
+
// browser named. Discovery is after admission, not module startup: the listener is not serving yet at start.
|
|
60
|
+
async #metadata() {
|
|
61
|
+
if (this.#authority !== null) return this.#authority;
|
|
62
|
+
const issuer = new URL(this.#face.issuer());
|
|
63
|
+
const options = { [oauth.allowInsecureRequests]: issuer.protocol === "http:" };
|
|
64
|
+
const response = await oauth.discoveryRequest(issuer, { ...options, algorithm: "oauth2" });
|
|
65
|
+
this.#authority = { server: await oauth.processDiscoveryResponse(issuer, response), options };
|
|
66
|
+
return this.#authority;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @param {SessionRequest} req */
|
|
70
|
+
#claims(req) {
|
|
71
|
+
const token = cookie(req, this.#session(req));
|
|
72
|
+
const claims = token === null ? null : this.#credentials.verify(token);
|
|
73
|
+
return claims?.kind === "session" && claims.clientId === this.#client.client_id && claims.scopes.includes("acp") ? claims : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @param {SessionRequest} req */
|
|
77
|
+
admitted(req) { return this.#claims(req) !== null; }
|
|
78
|
+
|
|
79
|
+
/** {§web-account} The authority owns the inventory; the cookie identifies its current family.
|
|
80
|
+
* @param {SessionRequest} req
|
|
81
|
+
*/
|
|
82
|
+
account(req) {
|
|
83
|
+
const claims = this.#claims(req);
|
|
84
|
+
return claims === null ? null : {
|
|
85
|
+
family: claims.family, sessions: this.#credentials.sessions(claims.subject),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {SessionRequest} req */
|
|
90
|
+
#mutation(req) {
|
|
91
|
+
const account = this.account(req);
|
|
92
|
+
if (account === null) throw new RequestError(401, "Sign in again before managing sessions.");
|
|
93
|
+
this.sameOriginForm(req);
|
|
94
|
+
return account;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @param {SessionRequest} req @param {SessionResponse} res @param {string} family */
|
|
98
|
+
revoke(req, res, family) {
|
|
99
|
+
const account = this.#mutation(req);
|
|
100
|
+
if (!account.sessions.some((entry) => entry.family === family)) throw new RequestError(404, "Session not found.");
|
|
101
|
+
if (family === account.family) { this.logout(req, res); return; }
|
|
102
|
+
this.#credentials.revokeFamily(family);
|
|
103
|
+
redirect(res, "/account/sessions/");
|
|
104
|
+
}
|
|
105
|
+
/** {§web-workspace-creation} Module-owned form mutations use the daemon's own origin.
|
|
106
|
+
* @param {SessionRequest} req
|
|
107
|
+
*/
|
|
108
|
+
sameOriginForm(req) {
|
|
109
|
+
if (req.headers.origin !== this.#issuer(req)) throw new RequestError(403, "Same-origin form required.");
|
|
110
|
+
sameOriginFetch(req);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @param {SessionRequest} req @param {SessionResponse} res */
|
|
114
|
+
revokeOthers(req, res) {
|
|
115
|
+
const account = this.#mutation(req);
|
|
116
|
+
for (const entry of account.sessions) if (entry.family !== account.family) this.#credentials.revokeFamily(entry.family);
|
|
117
|
+
redirect(res, "/account/sessions/");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** @param {SessionRequest} req */
|
|
121
|
+
#secure(req) { return this.#issuer(req).startsWith("https:"); }
|
|
122
|
+
|
|
123
|
+
/** @param {SessionRequest} req */
|
|
124
|
+
#session(req) { return `${this.#secure(req) ? HOST_ONLY : ""}${SESSION}`; }
|
|
125
|
+
|
|
126
|
+
/** @param {SessionRequest} req @param {string} state */
|
|
127
|
+
#transaction(req, state) { return `${this.#secure(req) ? SECURE_ONLY : ""}${TRANSACTION}${state}`; }
|
|
128
|
+
|
|
129
|
+
/** @param {SessionRequest} req @param {string} name @param {string} value @param {string} path @param {number} ttl */
|
|
130
|
+
#cookie(req, name, value, path, ttl) {
|
|
131
|
+
return `${name}=${value}; Path=${path}; HttpOnly; SameSite=Lax; Max-Age=${Math.max(0, Math.ceil(ttl))}`
|
|
132
|
+
+ (this.#secure(req) ? "; Secure" : "");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** {§web-session} Where a browser starts sign-in: the issuer's own address, so its transaction and callback share one host.
|
|
136
|
+
* @param {SessionRequest} req @param {string} returnTo
|
|
137
|
+
*/
|
|
138
|
+
signIn(req, returnTo) {
|
|
139
|
+
const location = new URL(`/auth/login?${new URLSearchParams({ return: returnTo })}`, this.#issuer(req));
|
|
140
|
+
return location.host === req.headers.host ? `${location.pathname}${location.search}` : location.href;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** @param {SessionRequest} req @param {SessionResponse} res */
|
|
144
|
+
async login(req, res) {
|
|
145
|
+
const issuer = this.#issuer(req);
|
|
146
|
+
const requested = new URL(req.url ?? "/", issuer).searchParams.get("return") ?? "/~/";
|
|
147
|
+
const directory = parseDirectory(requested);
|
|
148
|
+
const document = indexDocument(requested.split(/[?#]/u)[0]);
|
|
149
|
+
const target = ["/account/index.html", "/account/sessions/index.html"].includes(document) ? document.slice(0, -"index.html".length)
|
|
150
|
+
: directory === null ? null : directoryPath(directory);
|
|
151
|
+
if (target === null) throw new RequestError(400, "Invalid return page.");
|
|
152
|
+
if (this.admitted(req)) { redirect(res, target); return; }
|
|
153
|
+
if (this.#limits.ttl === null) throw new RequestError(503, "Browser sign-in is unavailable because PLURNK_WEB_LOGIN_TTL_SEC is invalid.");
|
|
154
|
+
const { server } = await this.#metadata();
|
|
155
|
+
if (!server.authorization_endpoint) throw new Error("The daemon's authorization metadata omits its authorization endpoint.");
|
|
156
|
+
|
|
157
|
+
const state = oauth.generateRandomState();
|
|
158
|
+
const verifier = oauth.generateRandomCodeVerifier();
|
|
159
|
+
const challenge = await oauth.calculatePKCECodeChallenge(verifier);
|
|
160
|
+
const redirectUri = `${issuer}${CALLBACK}`;
|
|
161
|
+
const name = this.#transaction(req, state);
|
|
162
|
+
const sealed = this.#seal(name, { verifier, target, redirectUri, expires: Date.now() + this.#limits.ttl * 1000 });
|
|
163
|
+
res.setHeader("set-cookie", this.#cookie(req, name, sealed, "/auth", this.#limits.ttl));
|
|
164
|
+
// The browser signs in at the daemon's authorization endpoint on the address it reached.
|
|
165
|
+
const url = new URL(new URL(server.authorization_endpoint).pathname, issuer);
|
|
166
|
+
url.search = new URLSearchParams({
|
|
167
|
+
client_id: this.#client.client_id, redirect_uri: redirectUri,
|
|
168
|
+
response_type: "code", scope: "acp", code_challenge_method: "S256", code_challenge: challenge, state,
|
|
169
|
+
}).toString();
|
|
170
|
+
redirect(res, url.href);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** @param {SessionRequest} req @param {SessionResponse} res */
|
|
174
|
+
async callback(req, res) {
|
|
175
|
+
const url = new URL(req.url ?? "/", this.#issuer(req));
|
|
176
|
+
const state = url.searchParams.get("state") ?? "";
|
|
177
|
+
const expired = new RequestError(400, "This browser holds no current sign-in for this callback. Start sign-in again.");
|
|
178
|
+
if (!/^[\w-]+$/u.test(state)) throw expired;
|
|
179
|
+
const name = this.#transaction(req, state);
|
|
180
|
+
const sealed = cookie(req, name);
|
|
181
|
+
// Single use: the browser drops the transaction whatever this callback's outcome.
|
|
182
|
+
const cleared = this.#cookie(req, name, "", "/auth", 0);
|
|
183
|
+
res.setHeader("set-cookie", cleared);
|
|
184
|
+
const transaction = sealed === null ? null : this.#open(name, sealed);
|
|
185
|
+
if (transaction === null || transaction.expires <= Date.now()) throw expired;
|
|
186
|
+
const { server, options } = await this.#metadata();
|
|
187
|
+
let params;
|
|
188
|
+
try {
|
|
189
|
+
params = oauth.validateAuthResponse(server, this.#client, url, state);
|
|
190
|
+
} catch (cause) {
|
|
191
|
+
throw new RequestError(400, "Sign-in was not completed. Start sign-in again.", { cause });
|
|
192
|
+
}
|
|
193
|
+
const response = await oauth.authorizationCodeGrantRequest(server, this.#client, this.#authenticate,
|
|
194
|
+
params, transaction.redirectUri, transaction.verifier, options);
|
|
195
|
+
let result;
|
|
196
|
+
try {
|
|
197
|
+
result = await oauth.processAuthorizationCodeResponse(server, this.#client, response);
|
|
198
|
+
} catch (cause) {
|
|
199
|
+
// The authority redeems a code once: a replayed callback is the browser's to restart.
|
|
200
|
+
if (cause instanceof oauth.ResponseBodyError && cause.error === "invalid_grant") {
|
|
201
|
+
throw new RequestError(400, "Sign-in was not completed. Start sign-in again.", { cause });
|
|
202
|
+
}
|
|
203
|
+
throw new RequestError(502, "The daemon did not complete sign-in. Start sign-in again.", { cause });
|
|
204
|
+
}
|
|
205
|
+
const grant = this.#grant(result);
|
|
206
|
+
const { claims } = grant;
|
|
207
|
+
const ttlMs = claims.expiresAt - Date.now();
|
|
208
|
+
const issued = this.#credentials.issue({ kind: "session", subject: claims.subject, clientId: claims.clientId,
|
|
209
|
+
scopes: claims.scopes, family: claims.family, ttlMs });
|
|
210
|
+
const old = this.#claims(req);
|
|
211
|
+
if (old !== null && old.family !== claims.family) this.#credentials.revokeFamily(old.family);
|
|
212
|
+
this.#prune();
|
|
213
|
+
this.#grants.set(claims.family, grant);
|
|
214
|
+
res.setHeader("set-cookie", [cleared, this.#cookie(req, this.#session(req), issued.token, "/", ttlMs / 1000)]);
|
|
215
|
+
redirect(res, transaction.target);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** {§web-session} A sign-in transaction travels sealed in its own browser cookie, named for its state.
|
|
219
|
+
* @param {string} name @param {Transaction} transaction
|
|
220
|
+
*/
|
|
221
|
+
#seal(name, transaction) {
|
|
222
|
+
const iv = randomBytes(12);
|
|
223
|
+
const cipher = createCipheriv("aes-256-gcm", this.#key, iv, { authTagLength: 16 }).setAAD(Buffer.from(name));
|
|
224
|
+
const body = Buffer.concat([cipher.update(JSON.stringify(transaction)), cipher.final()]);
|
|
225
|
+
return Buffer.concat([iv, body, cipher.getAuthTag()]).toString("base64url");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A cookie this module instance did not seal for this name opens to nothing.
|
|
229
|
+
* @param {string} name @param {string} sealed @returns {Transaction | null}
|
|
230
|
+
*/
|
|
231
|
+
#open(name, sealed) {
|
|
232
|
+
const bytes = Buffer.from(sealed, "base64url");
|
|
233
|
+
try {
|
|
234
|
+
const decipher = createDecipheriv("aes-256-gcm", this.#key, bytes.subarray(0, 12), { authTagLength: 16 }).setAAD(Buffer.from(name));
|
|
235
|
+
decipher.setAuthTag(bytes.subarray(-16));
|
|
236
|
+
return JSON.parse(Buffer.concat([decipher.update(bytes.subarray(12, -16)), decipher.final()]).toString("utf8"));
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** @param {oauth.TokenEndpointResponse} result @returns {Grant} */
|
|
243
|
+
#grant(result) {
|
|
244
|
+
const access = this.#credentials.verify(result.access_token);
|
|
245
|
+
const refresh = result.refresh_token === undefined ? null : this.#credentials.verify(result.refresh_token);
|
|
246
|
+
if (access?.kind !== "access" || refresh?.kind !== "refresh" || access.family !== refresh.family
|
|
247
|
+
|| access.clientId !== this.#client.client_id || refresh.clientId !== access.clientId
|
|
248
|
+
|| access.subject !== refresh.subject || !access.scopes.includes("acp") || !refresh.scopes.includes("acp")) {
|
|
249
|
+
throw new Error("The authorization server returned credentials outside the registered web grant.");
|
|
250
|
+
}
|
|
251
|
+
return { access: result.access_token, refresh: /** @type {string} */ (result.refresh_token), claims: refresh, refreshing: null };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#prune() {
|
|
255
|
+
for (const [family, grant] of this.#grants) {
|
|
256
|
+
if (grant.refreshing === null && this.#credentials.verify(grant.refresh) === null) this.#grants.delete(family);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** @param {Grant} grant */
|
|
261
|
+
async #refresh(grant) {
|
|
262
|
+
const { server, options } = await this.#metadata();
|
|
263
|
+
let result;
|
|
264
|
+
try {
|
|
265
|
+
const response = await oauth.refreshTokenGrantRequest(server, this.#client, this.#authenticate, grant.refresh, options);
|
|
266
|
+
result = await oauth.processRefreshTokenResponse(server, this.#client, response);
|
|
267
|
+
} catch (cause) {
|
|
268
|
+
if (cause instanceof oauth.ResponseBodyError && cause.error === "invalid_grant") {
|
|
269
|
+
this.#credentials.revokeFamily(grant.claims.family);
|
|
270
|
+
throw new RequestError(401, "Sign in again to connect.", { cause });
|
|
271
|
+
}
|
|
272
|
+
throw new RequestError(502, "The daemon could not refresh the connection credential.", { cause });
|
|
273
|
+
}
|
|
274
|
+
// Logout or a later callback must not be undone by an in-flight refresh.
|
|
275
|
+
if (this.#grants.get(grant.claims.family) !== grant) throw new RequestError(401, "Sign in again to connect.");
|
|
276
|
+
const next = this.#grant(result);
|
|
277
|
+
if (next.claims.family !== grant.claims.family || next.claims.subject !== grant.claims.subject) {
|
|
278
|
+
throw new Error("The authorization server changed the web grant during refresh.");
|
|
279
|
+
}
|
|
280
|
+
grant.access = next.access;
|
|
281
|
+
grant.refresh = next.refresh;
|
|
282
|
+
grant.claims = next.claims;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** {§web-access-token} Share one standard refresh across concurrent tabs; authority owns validity.
|
|
286
|
+
* @param {SessionRequest} req @param {SessionResponse} res
|
|
287
|
+
*/
|
|
288
|
+
async token(req, res) {
|
|
289
|
+
const claims = this.#claims(req);
|
|
290
|
+
if (claims === null) throw new RequestError(401, "Sign in again to connect.");
|
|
291
|
+
if (req.headers.origin !== this.#issuer(req)) throw new RequestError(403, "Same-origin request required.");
|
|
292
|
+
this.#prune();
|
|
293
|
+
const grant = this.#grants.get(claims.family);
|
|
294
|
+
if (grant === undefined) throw new RequestError(401, "Sign in again to connect.");
|
|
295
|
+
if (this.#credentials.verify(grant.access) === null) {
|
|
296
|
+
grant.refreshing ??= this.#refresh(grant).finally(() => { grant.refreshing = null; });
|
|
297
|
+
await grant.refreshing;
|
|
298
|
+
}
|
|
299
|
+
const access = this.#credentials.verify(grant.access);
|
|
300
|
+
if (this.#claims(req) === null || this.#grants.get(claims.family) !== grant || access === null) {
|
|
301
|
+
throw new RequestError(401, "Sign in again to connect.");
|
|
302
|
+
}
|
|
303
|
+
const body = Buffer.from(JSON.stringify({ access_token: grant.access, token_type: "Bearer",
|
|
304
|
+
expires_in: Math.max(0, Math.floor((access.expiresAt - Date.now()) / 1000)) }));
|
|
305
|
+
res.writeHead(200, { ...privateHeaders, "content-type": "application/json", "content-length": body.byteLength });
|
|
306
|
+
res.end(body);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** @param {SessionRequest} req @param {SessionResponse} res */
|
|
310
|
+
logout(req, res) {
|
|
311
|
+
this.sameOriginForm(req);
|
|
312
|
+
const claims = this.#claims(req);
|
|
313
|
+
if (claims !== null) this.#credentials.revokeFamily(claims.family);
|
|
314
|
+
res.setHeader("set-cookie", this.#cookie(req, this.#session(req), "", "/", 0));
|
|
315
|
+
redirect(res, "/");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
close() { this.#grants.clear(); this.#unsubscribe(); }
|
|
319
|
+
}
|