@timqi/pier 0.0.4 → 0.0.6
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 +9 -6
- package/dist/agent/events.js +6 -1
- package/dist/agent/pi.js +28 -3
- package/dist/channels/chunk.js +34 -0
- package/dist/channels/control.js +6 -12
- package/dist/channels/dedup.js +45 -0
- package/dist/channels/lark-api.js +233 -0
- package/dist/channels/lark-outbound.js +101 -0
- package/dist/channels/lark-panel.js +95 -0
- package/dist/channels/lark-render.js +107 -0
- package/dist/channels/lark.js +501 -0
- package/dist/channels/lines.js +19 -0
- package/dist/channels/panel.js +4 -0
- package/dist/channels/receipts.js +15 -0
- package/dist/channels/routes.js +0 -1
- package/dist/channels/runtime.js +5 -1
- package/dist/channels/slack-api.js +4 -2
- package/dist/channels/slack-panel.js +3 -6
- package/dist/channels/slack-render.js +3 -23
- package/dist/channels/slack.js +39 -72
- package/dist/channels/telegram-api.js +4 -2
- package/dist/channels/telegram-panel.js +3 -3
- package/dist/channels/telegram.js +55 -51
- package/dist/channels/types.js +9 -0
- package/dist/cli.js +3 -1
- package/dist/core/inbox.js +67 -1
- package/dist/core/types.js +4 -0
- package/dist/db.js +83 -20
- package/dist/main.js +5 -0
- package/dist/service.js +1 -1
- package/dist/web/auth.js +36 -9
- package/dist/web/public/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
- package/dist/web/public/assets/ghostty-web-ODXT71Ln.js +13 -0
- package/dist/web/public/assets/index-BUNGxtMe.css +2 -0
- package/dist/web/public/assets/index-QPYgeBhQ.js +90 -0
- package/dist/web/public/index.html +10 -2
- package/dist/web/server.js +86 -19
- package/dist/web/session-state.js +59 -25
- package/dist/web/terminal.js +334 -0
- package/docs/deploy.md +33 -21
- package/package.json +10 -2
- package/dist/web/public/assets/index-B3MvJUJP.js +0 -90
- package/dist/web/public/assets/index-CwBoxtXP.css +0 -2
package/dist/core/inbox.js
CHANGED
|
@@ -10,7 +10,7 @@ import { mkdir, writeFile } from "node:fs/promises";
|
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
11
|
import { basename, join } from "node:path";
|
|
12
12
|
import { pierPath } from "../paths.js";
|
|
13
|
-
import { safeName } from "./inbound-file.js";
|
|
13
|
+
import { fileMarker, lostMarker, MAX_INBOUND_BYTES, safeName } from "./inbound-file.js";
|
|
14
14
|
/** Where every inbound file lives; web/files.ts allowlists this root. */
|
|
15
15
|
export const INBOX_DIR = pierPath("inbox");
|
|
16
16
|
/**
|
|
@@ -30,3 +30,69 @@ export async function saveInbound(channelId, name, mimeType, bytes) {
|
|
|
30
30
|
await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
|
|
31
31
|
return path;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Collect a fetch response's body, refusing past `maxBytes` mid-stream. The
|
|
35
|
+
* metadata size gate in saveInboundAll is only as honest as the platform's
|
|
36
|
+
* metadata — absent or wrong, `arrayBuffer()` buffers whatever arrives — so
|
|
37
|
+
* the read itself is bounded too. Throws with "too large" in the message,
|
|
38
|
+
* which the loop below translates into the honest lost-marker reason.
|
|
39
|
+
*/
|
|
40
|
+
export async function readCapped(body, maxBytes) {
|
|
41
|
+
if (!body)
|
|
42
|
+
return new Uint8Array(0);
|
|
43
|
+
const parts = [];
|
|
44
|
+
let size = 0;
|
|
45
|
+
const reader = body.getReader();
|
|
46
|
+
try {
|
|
47
|
+
for (;;) {
|
|
48
|
+
const { done, value } = await reader.read();
|
|
49
|
+
if (done)
|
|
50
|
+
break;
|
|
51
|
+
size += value.byteLength;
|
|
52
|
+
if (size > maxBytes)
|
|
53
|
+
throw new Error(`attachment too large (>${maxBytes} bytes)`);
|
|
54
|
+
parts.push(value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
// Also cancels the transfer on the too-large throw.
|
|
59
|
+
reader.releaseLock();
|
|
60
|
+
await body.cancel().catch(() => { });
|
|
61
|
+
}
|
|
62
|
+
const bytes = new Uint8Array(size);
|
|
63
|
+
let at = 0;
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
bytes.set(part, at);
|
|
66
|
+
at += part.byteLength;
|
|
67
|
+
}
|
|
68
|
+
return bytes;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Save a message's attachments; each becomes a marker line for the prompt —
|
|
72
|
+
* and a failed or oversized one becomes a lost-marker line, never silence
|
|
73
|
+
* (5b). Written three times, once per adapter, before landing here: the
|
|
74
|
+
* size gate before the fetch (an unauthorized sender is already filtered by
|
|
75
|
+
* then, but a movie must not be buffered whole either) and the never-silent
|
|
76
|
+
* failure path are invariants, and invariants drift when copied.
|
|
77
|
+
*/
|
|
78
|
+
export async function saveInboundAll(channelId, files, log) {
|
|
79
|
+
const markers = [];
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
if (file.size !== undefined && file.size > MAX_INBOUND_BYTES) {
|
|
82
|
+
markers.push(lostMarker(file.label, "too large"));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const got = await file.fetch();
|
|
87
|
+
const path = await saveInbound(channelId, file.name ?? got.name, got.mimeType ?? file.mimeType, got.bytes);
|
|
88
|
+
markers.push(fileMarker(path));
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
log(`attachment download failed: ${String(err)}`);
|
|
92
|
+
// A fetch that refused mid-stream names its reason; keep it honest.
|
|
93
|
+
const why = String(err).includes("too large") ? "too large" : "download failed";
|
|
94
|
+
markers.push(lostMarker(file.label, why));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return markers;
|
|
98
|
+
}
|
package/dist/core/types.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// Normative seam types — THIS FILE is the system contract (docs/architecture.md
|
|
2
2
|
// documents the rules around it). Changing a seam is a design decision, not a
|
|
3
3
|
// refactor; keep it implementable over RPC (no Pi types may appear here).
|
|
4
|
+
/** How much of a tool result any surface ever shows. A transcript replay
|
|
5
|
+
* carries no more than that: a session's tool output is most of its history
|
|
6
|
+
* payload, and the bytes past this point were downloaded to be sliced off. */
|
|
7
|
+
export const MAX_STEP_OUTPUT = 8_000;
|
|
4
8
|
/** Every level Pi accepts, in order. The union is derived so the two cannot
|
|
5
9
|
* drift, and boundary validators use isThinkingLevel instead of their own copy. */
|
|
6
10
|
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
package/dist/db.js
CHANGED
|
@@ -11,15 +11,16 @@
|
|
|
11
11
|
// So: one connection, one ordered list of migrations, applied in one
|
|
12
12
|
// transaction before any store exists. A store receives the handle and owns
|
|
13
13
|
// only its queries.
|
|
14
|
-
import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
|
|
14
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
|
|
15
15
|
import { basename, dirname, join } from "node:path";
|
|
16
16
|
import { DatabaseSync } from "node:sqlite";
|
|
17
17
|
import { logger } from "./log.js";
|
|
18
18
|
import { PIER_DB } from "./paths.js";
|
|
19
19
|
const log = logger("db");
|
|
20
|
-
/**
|
|
20
|
+
/** Snapshots to keep *of each kind*. Three is two upgrades of regret plus one:
|
|
21
21
|
* they are full copies of the database, and the one that matters is the
|
|
22
|
-
* newest.
|
|
22
|
+
* newest. Counted per kind because the two kinds answer different questions —
|
|
23
|
+
* a run of releases must not evict the pre-migration copies. */
|
|
23
24
|
const KEEP_BACKUPS = 3;
|
|
24
25
|
/** How long a second process may wait for the write lock before failing. Two
|
|
25
26
|
* Pier processes on one PIER_HOME contend exactly once — at boot, when both
|
|
@@ -138,6 +139,12 @@ const MIGRATIONS = [
|
|
|
138
139
|
note TEXT NOT NULL,
|
|
139
140
|
created_at INTEGER NOT NULL
|
|
140
141
|
);
|
|
142
|
+
`,
|
|
143
|
+
// 4 — Projects can render from SQLite without scanning every Pi transcript.
|
|
144
|
+
`
|
|
145
|
+
ALTER TABLE session_state ADD COLUMN cwd TEXT;
|
|
146
|
+
ALTER TABLE session_state ADD COLUMN title TEXT;
|
|
147
|
+
ALTER TABLE session_state ADD COLUMN created_at INTEGER;
|
|
141
148
|
`,
|
|
142
149
|
];
|
|
143
150
|
let shared;
|
|
@@ -147,14 +154,25 @@ let shared;
|
|
|
147
154
|
*/
|
|
148
155
|
export const pierDb = () => (shared ??= openDb(PIER_DB));
|
|
149
156
|
/** A release-level restore point, taken while the service is stopped even when
|
|
150
|
-
* the release has no schema migration. The previous complete
|
|
151
|
-
* writing
|
|
152
|
-
|
|
157
|
+
* the release has no schema migration. The previous complete copies stay put if
|
|
158
|
+
* writing this one fails.
|
|
159
|
+
*
|
|
160
|
+
* `version` is the Pier that produced this database, not the one being
|
|
161
|
+
* installed: the updater runs this from the tree it is about to replace, and
|
|
162
|
+
* restoring a database means reinstalling the code that speaks its schema
|
|
163
|
+
* (`migrate` refuses one from a newer Pier). So the name carries the other half
|
|
164
|
+
* of the pair. Backing up twice at one version replaces that version's copy —
|
|
165
|
+
* the pairing is identical, so a second name for it would say nothing. */
|
|
166
|
+
export function backupDb(version, path = PIER_DB) {
|
|
153
167
|
if (!existsSync(path))
|
|
154
168
|
return undefined;
|
|
155
|
-
|
|
169
|
+
// In a filename, so it may not carry a separator or a traversal; a version
|
|
170
|
+
// this malformed is a broken install, not something to guess at.
|
|
171
|
+
const safe = version.replaceAll(/[^0-9A-Za-z.+-]/g, "_") || "unknown";
|
|
172
|
+
const bak = join(backupsDir(path, true), `${basename(path)}.release-${safe}.bak`);
|
|
156
173
|
copyDatabase(path, bak);
|
|
157
174
|
log.info(`pre-update backup: ${bak}`);
|
|
175
|
+
prune(releases(path));
|
|
158
176
|
return bak;
|
|
159
177
|
}
|
|
160
178
|
/** Open a database, bring it to the current schema, and lock down its files.
|
|
@@ -186,9 +204,9 @@ function migrate(db, path, migrations) {
|
|
|
186
204
|
if (at > target) {
|
|
187
205
|
// Name the snapshot that exists rather than a pattern: the operator is
|
|
188
206
|
// reading this because the service will not start.
|
|
189
|
-
const newest = path === ":memory:" ? undefined :
|
|
207
|
+
const newest = path === ":memory:" ? undefined : snapshots(path)[0]?.file;
|
|
190
208
|
throw new Error(`${path} is at schema ${at}, this Pier speaks ${target}: a database is ` +
|
|
191
|
-
`never downgraded. Restore ${newest ??
|
|
209
|
+
`never downgraded. Restore ${newest ?? `a copy from ${backupsDir(path)}`}, or run the newer Pier.`);
|
|
192
210
|
}
|
|
193
211
|
// Version 0 with tables is a database from before versioning existed.
|
|
194
212
|
// Migration 1 assumes an empty file, so the collision it would hit says
|
|
@@ -244,7 +262,7 @@ function migrate(db, path, migrations) {
|
|
|
244
262
|
}
|
|
245
263
|
log.info(locked === 0 ? `schema created at version ${target}` : `schema ${locked} → ${target}`);
|
|
246
264
|
if (path !== ":memory:")
|
|
247
|
-
prune(path);
|
|
265
|
+
prune(snapshots(path).map(({ file }) => file));
|
|
248
266
|
}
|
|
249
267
|
/**
|
|
250
268
|
* The copy that exists because `user_version` only counts up: the transaction
|
|
@@ -259,7 +277,7 @@ function migrate(db, path, migrations) {
|
|
|
259
277
|
* ever refers to a finished copy.
|
|
260
278
|
*/
|
|
261
279
|
function snapshot(path, at) {
|
|
262
|
-
const bak = `${path}.v${at}.bak
|
|
280
|
+
const bak = join(backupsDir(path, true), `${basename(path)}.v${at}.bak`);
|
|
263
281
|
copyDatabase(path, bak);
|
|
264
282
|
log.info(`pre-migration backup: ${bak}`);
|
|
265
283
|
}
|
|
@@ -276,22 +294,64 @@ function copyDatabase(path, bak) {
|
|
|
276
294
|
chmodSync(tmp, 0o600); // it holds everything the 0600 database holds
|
|
277
295
|
renameSync(tmp, bak);
|
|
278
296
|
}
|
|
279
|
-
/**
|
|
280
|
-
|
|
297
|
+
/**
|
|
298
|
+
* One directory for every copy of this database, `db/backups/`. Beside the
|
|
299
|
+
* database was fine while there was one snapshot per schema; a restore point
|
|
300
|
+
* per release turns that into a listing where the live file and its sidecars
|
|
301
|
+
* are hard to pick out, and "which of these do I not delete" is the wrong
|
|
302
|
+
* question to make an operator answer under pressure.
|
|
303
|
+
*
|
|
304
|
+
* `create` also adopts what an older Pier wrote next to the database, so the
|
|
305
|
+
* restore procedure names one location instead of two forever.
|
|
306
|
+
*/
|
|
307
|
+
function backupsDir(path, create = false) {
|
|
308
|
+
const dir = join(dirname(path), "backups");
|
|
309
|
+
if (!create)
|
|
310
|
+
return dir;
|
|
311
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
312
|
+
const prefix = `${basename(path)}.`;
|
|
313
|
+
for (const name of readdirSync(dirname(path))) {
|
|
314
|
+
if (!name.startsWith(prefix) || !name.endsWith(".bak"))
|
|
315
|
+
continue;
|
|
316
|
+
renameSync(join(dirname(path), name), join(dir, name));
|
|
317
|
+
log.info(`moved ${name} into ${dir}`);
|
|
318
|
+
}
|
|
319
|
+
return dir;
|
|
320
|
+
}
|
|
321
|
+
/** The copies of one kind: `v<schema>` or `release-<version>`. Disjoint
|
|
322
|
+
* prefixes, so each kind is counted and pruned on its own. */
|
|
323
|
+
function listBackups(path, kind) {
|
|
324
|
+
const dir = backupsDir(path);
|
|
325
|
+
if (!existsSync(dir))
|
|
326
|
+
return [];
|
|
327
|
+
const prefix = `${basename(path)}.${kind}`;
|
|
328
|
+
return readdirSync(dir).filter((name) => name.startsWith(prefix) && name.endsWith(".bak"));
|
|
329
|
+
}
|
|
330
|
+
/** Pre-migration snapshots, newest schema first — the number in the name is an
|
|
331
|
+
* ordinal, so it orders them without asking the filesystem. */
|
|
332
|
+
function snapshots(path) {
|
|
281
333
|
const prefix = `${basename(path)}.v`;
|
|
282
|
-
return
|
|
283
|
-
.filter((name) => name.startsWith(prefix) && name.endsWith(".bak"))
|
|
334
|
+
return listBackups(path, "v")
|
|
284
335
|
.map((name) => ({
|
|
285
336
|
version: Number(name.slice(prefix.length, -".bak".length)),
|
|
286
|
-
file: join(
|
|
337
|
+
file: join(backupsDir(path), name),
|
|
287
338
|
}))
|
|
288
339
|
.filter(({ version }) => Number.isInteger(version))
|
|
289
340
|
.sort((a, b) => b.version - a.version);
|
|
290
341
|
}
|
|
291
|
-
/**
|
|
292
|
-
*
|
|
293
|
-
|
|
294
|
-
|
|
342
|
+
/** Release restore points, newest copy first. Ordered by mtime: the name holds
|
|
343
|
+
* a Pier version, and comparing those means reimplementing semver here — while
|
|
344
|
+
* two updates of one instance are never in flight at the same moment. Legacy
|
|
345
|
+
* `pier.db.release.bak` shares the prefix, so it ages out like the rest. */
|
|
346
|
+
function releases(path) {
|
|
347
|
+
return listBackups(path, "release")
|
|
348
|
+
.map((name) => join(backupsDir(path), name))
|
|
349
|
+
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
350
|
+
}
|
|
351
|
+
/** Keep the newest few, oldest first out. Nobody restores a database from four
|
|
352
|
+
* upgrades ago, and every one of these is the size of the whole database. */
|
|
353
|
+
function prune(newestFirst) {
|
|
354
|
+
for (const file of newestFirst.slice(KEEP_BACKUPS)) {
|
|
295
355
|
rmSync(file, { force: true });
|
|
296
356
|
log.info(`removed superseded backup: ${file}`);
|
|
297
357
|
}
|
|
@@ -311,4 +371,7 @@ function restrict(path) {
|
|
|
311
371
|
chmodSync(file, 0o600);
|
|
312
372
|
}
|
|
313
373
|
chmodSync(dirname(path), 0o700);
|
|
374
|
+
// Full copies of the same secrets, one directory down.
|
|
375
|
+
if (existsSync(backupsDir(path)))
|
|
376
|
+
chmodSync(backupsDir(path), 0o700);
|
|
314
377
|
}
|
package/dist/main.js
CHANGED
|
@@ -34,6 +34,7 @@ import { startAutoUpdate, UpdateCheck } from "./update.js";
|
|
|
34
34
|
import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
|
|
35
35
|
import { SessionStateStore } from "./web/session-state.js";
|
|
36
36
|
import { createServer } from "./web/server.js";
|
|
37
|
+
import { attachTerminal } from "./web/terminal.js";
|
|
37
38
|
const log = logger("pier");
|
|
38
39
|
// Pier owns the Pi runtime dir. Set before any SDK call resolves a path, so
|
|
39
40
|
// everything Pi derives from its agent dir (auth.json, models.json, sessions,
|
|
@@ -280,6 +281,9 @@ const server = serve({ fetch: app.fetch, port, hostname }, () => {
|
|
|
280
281
|
log.info(`workbench on http://${hostname}:${port}`);
|
|
281
282
|
log.info(`pid ${process.pid}, node ${process.version}, home ${PIER_HOME}`);
|
|
282
283
|
});
|
|
284
|
+
// The one WebSocket surface (see web/terminal.ts); `serve` above builds a
|
|
285
|
+
// plain node:http server, which is the only shape with an upgrade event.
|
|
286
|
+
const terminals = attachTerminal(server, auth);
|
|
283
287
|
// A crash and a clean stop must be distinguishable after the fact, and both
|
|
284
288
|
// left nothing behind before this.
|
|
285
289
|
process.on("uncaughtException", (err) => {
|
|
@@ -302,6 +306,7 @@ const shutdown = (stopTasks = true) => {
|
|
|
302
306
|
// `systemctl restart` into a 90-second wait for SIGKILL.
|
|
303
307
|
setTimeout(() => process.exit(0), 3000).unref();
|
|
304
308
|
stopEviction();
|
|
309
|
+
terminals.close(); // no shell outlives the workbench
|
|
305
310
|
// The drain path leaves task runs alone: aborting them here would record
|
|
306
311
|
// them cancelled and race their callbacks against dying channels, when the
|
|
307
312
|
// boot-time interrupted marking is the recovery that was promised.
|
package/dist/service.js
CHANGED
|
@@ -300,7 +300,7 @@ export function startUpdate(options) {
|
|
|
300
300
|
if (!run(["systemctl", "--user", "start", "--no-block", UPDATE_UNIT_NAME]))
|
|
301
301
|
return "failed";
|
|
302
302
|
say(`updating in the background — follow it with: journalctl --user -u ${UPDATE_UNIT_NAME} -f`);
|
|
303
|
-
say(`Pier stops, snapshots
|
|
303
|
+
say(`Pier stops, snapshots the database into db/backups/, installs, then starts again.`);
|
|
304
304
|
return "started";
|
|
305
305
|
}
|
|
306
306
|
export function uninstall(home = homedir(), say = console.log, exec) {
|
package/dist/web/auth.js
CHANGED
|
@@ -59,6 +59,7 @@ export class AuthStore {
|
|
|
59
59
|
#db;
|
|
60
60
|
/** HMAC key for cookies: the hash, so rotating the password expires them. */
|
|
61
61
|
#key;
|
|
62
|
+
#rotationListeners = new Set();
|
|
62
63
|
constructor(db = pierDb(), print = (m) => log.info(m)) {
|
|
63
64
|
this.#db = db;
|
|
64
65
|
let row = this.#row();
|
|
@@ -99,6 +100,13 @@ export class AuthStore {
|
|
|
99
100
|
.prepare("UPDATE auth SET salt = ?, hash = ?, created_at = ? WHERE id = 1")
|
|
100
101
|
.run(salt, next, Date.now());
|
|
101
102
|
this.#key = next;
|
|
103
|
+
for (const listener of this.#rotationListeners)
|
|
104
|
+
listener();
|
|
105
|
+
}
|
|
106
|
+
/** A long-lived authenticated surface closes itself when every cookie is
|
|
107
|
+
* revoked. The store and listeners share the process lifetime. */
|
|
108
|
+
onRotation(listener) {
|
|
109
|
+
this.#rotationListeners.add(listener);
|
|
102
110
|
}
|
|
103
111
|
/** Cookie signing key. Never the password: that is not stored anywhere. */
|
|
104
112
|
get cookieKey() {
|
|
@@ -181,19 +189,14 @@ function noteFailure(client) {
|
|
|
181
189
|
failures.set(client, { count: 1, resetAt: Date.now() + WINDOW_MS });
|
|
182
190
|
}
|
|
183
191
|
/** Browsers name the source of unsafe requests. Compare hosts rather than
|
|
184
|
-
* schemes because TLS commonly terminates at the reverse proxy.
|
|
185
|
-
|
|
186
|
-
|
|
192
|
+
* schemes because TLS commonly terminates at the reverse proxy. Shared by HTTP
|
|
193
|
+
* and WebSocket so the password boundary cannot disagree with itself. */
|
|
194
|
+
function originMatches(origin, host) {
|
|
187
195
|
if (!origin)
|
|
188
196
|
return true; // curl and other non-browser clients
|
|
189
197
|
try {
|
|
190
198
|
const parsed = new URL(origin);
|
|
191
|
-
const
|
|
192
|
-
const forwarded = remote && loopback(remote)
|
|
193
|
-
? c.req.header("x-forwarded-host")?.split(",").at(-1)?.trim()
|
|
194
|
-
: undefined;
|
|
195
|
-
const host = forwarded || c.req.header("host") || new URL(c.req.url).host;
|
|
196
|
-
const external = new URL(`${parsed.protocol}//${host}`);
|
|
199
|
+
const external = new URL(`${parsed.protocol}//${host ?? ""}`);
|
|
197
200
|
return parsed.origin === origin && external.origin === parsed.origin &&
|
|
198
201
|
external.pathname === "/" && !external.search && !external.hash;
|
|
199
202
|
}
|
|
@@ -201,6 +204,30 @@ function sameOrigin(c) {
|
|
|
201
204
|
return false;
|
|
202
205
|
}
|
|
203
206
|
}
|
|
207
|
+
function sameOrigin(c) {
|
|
208
|
+
const remote = remoteOf(c);
|
|
209
|
+
const forwarded = remote && loopback(remote)
|
|
210
|
+
? c.req.header("x-forwarded-host")?.split(",").at(-1)?.trim()
|
|
211
|
+
: undefined;
|
|
212
|
+
return originMatches(c.req.header("origin"), forwarded || c.req.header("host") || new URL(c.req.url).host);
|
|
213
|
+
}
|
|
214
|
+
/** The same cookie + Origin boundary for a WebSocket upgrade, where no Hono
|
|
215
|
+
* context exists before the handshake completes. */
|
|
216
|
+
export function upgradeAuthorized(store, req) {
|
|
217
|
+
const raw = req.headers.cookie
|
|
218
|
+
?.split(";")
|
|
219
|
+
.map((part) => part.trim())
|
|
220
|
+
.find((part) => part.startsWith(`${COOKIE}=`))
|
|
221
|
+
?.slice(COOKIE.length + 1);
|
|
222
|
+
if (!valid(store.cookieKey, raw))
|
|
223
|
+
return false;
|
|
224
|
+
const remote = req.socket.remoteAddress ?? "";
|
|
225
|
+
const forwardedHeader = req.headers["x-forwarded-host"];
|
|
226
|
+
const forwarded = loopback(remote) && typeof forwardedHeader === "string"
|
|
227
|
+
? forwardedHeader.split(",").at(-1)?.trim()
|
|
228
|
+
: undefined;
|
|
229
|
+
return originMatches(req.headers.origin, forwarded || req.headers.host);
|
|
230
|
+
}
|
|
204
231
|
/** Every route, in one place — no per-route opt-in to forget on the next one. */
|
|
205
232
|
export function requireAuth(store) {
|
|
206
233
|
return async (c, next) => {
|
|
File without changes
|