@timqi/pier 0.0.7 → 0.0.9
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 +26 -9
- package/dist/agent/pi.js +103 -5
- package/dist/channels/conversations.js +10 -0
- package/dist/core/router.js +27 -11
- package/dist/db.js +30 -0
- package/dist/extensions/index.js +34 -0
- package/dist/extensions/web/anthropic.js +118 -0
- package/dist/extensions/web/artifacts.js +57 -0
- package/dist/extensions/web/content.js +130 -0
- package/dist/extensions/web/http.js +106 -0
- package/dist/extensions/web/index.js +9 -0
- package/dist/extensions/web/json.js +5 -0
- package/dist/extensions/web/language.js +47 -0
- package/dist/extensions/web/openai.js +112 -0
- package/dist/extensions/web/provider.js +121 -0
- package/dist/extensions/web/tools.js +284 -0
- package/dist/main.js +44 -6
- package/dist/paths.js +15 -0
- package/dist/settings.js +68 -13
- package/dist/web/instance.js +32 -8
- package/dist/web/providers.js +16 -0
- package/dist/web/public/assets/{ghostty-web-BhZV0Vvv.js → ghostty-web-C4N9kjtH.js} +1 -1
- package/dist/web/public/assets/index-DNCJJRSS.js +91 -0
- package/dist/web/public/assets/index-DYl1xk5y.css +2 -0
- package/dist/web/public/index.html +27 -4
- package/dist/web/public/manifest.webmanifest +11 -1
- package/dist/web/public/sw.js +109 -0
- package/dist/web/push.js +233 -0
- package/dist/web/server.js +51 -4
- package/dist/web/session-state.js +48 -9
- package/dist/web/terminal.js +34 -4
- package/dist/web/webpush.js +131 -0
- package/package.json +1 -1
- package/skills/pier-help/SKILL.md +12 -0
- package/dist/web/public/assets/index-BbwoGR-O.js +0 -90
- package/dist/web/public/assets/index-BlHvP59B.css +0 -2
package/dist/web/server.js
CHANGED
|
@@ -37,7 +37,7 @@ export const withTabPrefix = (html, prefix) => prefix
|
|
|
37
37
|
const HEARTBEAT_MS = 15_000;
|
|
38
38
|
// Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
|
|
39
39
|
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
40
|
-
export function createServer({ factory, router, hub, sessions: state, config, providers, settings, secrets, onUnlocked, reload, updates, updater, backgroundRuns, }) {
|
|
40
|
+
export function createServer({ factory, router, hub, sessions: state, config, providers, settings, extensions, secrets, onUnlocked, reload, updates, updater, backgroundRuns, channelOf, }) {
|
|
41
41
|
const app = new Hono();
|
|
42
42
|
// A finished turn marks its session unread until some client reports it was
|
|
43
43
|
// seen (session selected + tab visible → POST read below). Server-side so
|
|
@@ -77,11 +77,15 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
77
77
|
.finally(() => {
|
|
78
78
|
listing = undefined;
|
|
79
79
|
});
|
|
80
|
-
|
|
80
|
+
// `order` is where the workbench was arranged to put this row, not a fact
|
|
81
|
+
// about the Pi session — it rides along so one Projects read is enough.
|
|
82
|
+
const present = (s, pinned, unread, order = {}) => ({
|
|
81
83
|
...s,
|
|
84
|
+
...order,
|
|
82
85
|
state: router.stateOf(s.id) ?? "idle",
|
|
83
86
|
pinned,
|
|
84
87
|
unread,
|
|
88
|
+
channel: channelOf?.(s.id) ?? "web",
|
|
85
89
|
activeRuns: activeRuns(s.id),
|
|
86
90
|
});
|
|
87
91
|
app.get("/api/projects", async (c) => {
|
|
@@ -93,7 +97,27 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
93
97
|
// happened to be unreadable. A later explicit full listing can repair it.
|
|
94
98
|
projectBackfillNeeded = false;
|
|
95
99
|
}
|
|
96
|
-
return c.json(state.projects().map((s) => present(s, true, s.unread)));
|
|
100
|
+
return c.json(state.projects().map(({ sort, projectSort, ...s }) => present(s, true, s.unread, { sort, projectSort })));
|
|
101
|
+
});
|
|
102
|
+
// One drag, one write of the list that changed: the projects, or one
|
|
103
|
+
// project's sessions. Whole lists rather than a move — the client has just
|
|
104
|
+
// rendered the result, and replaying a move on top of a stale list would put
|
|
105
|
+
// the row somewhere nobody dropped it.
|
|
106
|
+
app.post("/api/projects/order", async (c) => {
|
|
107
|
+
const body = await c.req.json().catch(() => null);
|
|
108
|
+
const list = (raw) => raw === undefined
|
|
109
|
+
? undefined
|
|
110
|
+
: Array.isArray(raw) && raw.every((x) => typeof x === "string" && x)
|
|
111
|
+
? raw
|
|
112
|
+
: null;
|
|
113
|
+
const sessions = list(body?.sessions);
|
|
114
|
+
const projects = list(body?.projects);
|
|
115
|
+
if (sessions === null || projects === null || (!sessions && !projects)) {
|
|
116
|
+
return c.json({ error: "sessions and/or projects must be lists of ids" }, 400);
|
|
117
|
+
}
|
|
118
|
+
state.reorder({ sessions, projects });
|
|
119
|
+
hub.emitWorkspace({ type: "sessions-changed" });
|
|
120
|
+
return c.json({ ok: true });
|
|
97
121
|
});
|
|
98
122
|
app.get("/api/sessions", async (c) => {
|
|
99
123
|
const sessions = await listSessions();
|
|
@@ -106,7 +130,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
106
130
|
const flags = state.flags();
|
|
107
131
|
return c.json([...[...nascent].map(([id, n]) => ({ id, ...n })), ...sessions].map((s) => {
|
|
108
132
|
const row = flags.get(s.id);
|
|
109
|
-
return present(s, row?.pinned ?? false, row?.unread ?? false
|
|
133
|
+
return present(s, row?.pinned ?? false, row?.unread ?? false, {
|
|
134
|
+
sort: row?.sort,
|
|
135
|
+
projectSort: row?.projectSort,
|
|
136
|
+
});
|
|
110
137
|
}));
|
|
111
138
|
});
|
|
112
139
|
app.post("/api/sessions", async (c) => {
|
|
@@ -396,6 +423,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
396
423
|
updates,
|
|
397
424
|
updater,
|
|
398
425
|
secrets,
|
|
426
|
+
extensions,
|
|
399
427
|
onUnlocked,
|
|
400
428
|
onSettingsChanged: () => recycle("instance settings"),
|
|
401
429
|
});
|
|
@@ -422,6 +450,9 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
422
450
|
const prefix = tabPrefix(process.env.PIER_TITLE, hostname().split(".")[0] ?? "");
|
|
423
451
|
let shell = null;
|
|
424
452
|
app.get("/", async (c, next) => {
|
|
453
|
+
// A release replaces hashed assets. Revalidate the shell on every
|
|
454
|
+
// navigation so a cached index cannot name bundles that no longer exist.
|
|
455
|
+
c.header("cache-control", "private, no-cache");
|
|
425
456
|
if (shell === null) {
|
|
426
457
|
try {
|
|
427
458
|
shell = withTabPrefix(await readFile(join(bundle, "index.html"), "utf8"), prefix);
|
|
@@ -435,6 +466,22 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
435
466
|
}
|
|
436
467
|
return c.html(shell);
|
|
437
468
|
});
|
|
469
|
+
// Same reasoning as the shell above, for the one asset that is not hashed:
|
|
470
|
+
// an installed app keeps its worker until the script it re-fetches differs,
|
|
471
|
+
// so a cached copy is a released fix that never ships.
|
|
472
|
+
app.get("/sw.js", async (c, next) => {
|
|
473
|
+
c.header("cache-control", "private, no-cache");
|
|
474
|
+
await next();
|
|
475
|
+
});
|
|
476
|
+
// Hashed bundles never change under their name — a release writes new names,
|
|
477
|
+
// and the shell above is what re-points at them. Without this they carry only
|
|
478
|
+
// the auth layer's bare `private`, so a browser revalidates each one before it
|
|
479
|
+
// may reuse it: three round trips on a remote instance, one of them in front
|
|
480
|
+
// of the 636KB terminal emulator, every time the workbench is opened.
|
|
481
|
+
app.get("/assets/*", async (c, next) => {
|
|
482
|
+
c.header("cache-control", "private, max-age=31536000, immutable");
|
|
483
|
+
await next();
|
|
484
|
+
});
|
|
438
485
|
app.use("/*", serveStatic({ root: relative(process.cwd(), bundle) || "." }));
|
|
439
486
|
return app;
|
|
440
487
|
}
|
|
@@ -22,26 +22,53 @@ export class SessionStateStore {
|
|
|
22
22
|
}
|
|
23
23
|
/** Pin plus the summary Projects needs, atomically in one row. */
|
|
24
24
|
pin(summary, pinned) {
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// A new session joins a project that already has a place in the list.
|
|
26
|
+
// Unranked it would sort on top — lifting the whole project with it, which
|
|
27
|
+
// is the jump manual order exists to stop.
|
|
28
|
+
const sibling = this.#db.prepare("SELECT project_sort AS rank FROM session_state WHERE cwd = ? AND project_sort IS NOT NULL LIMIT 1").get(summary.cwd);
|
|
29
|
+
this.#db.prepare(`INSERT INTO session_state(session_id, pinned, cwd, title, created_at, project_sort)
|
|
30
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
27
31
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
28
32
|
pinned = excluded.pinned,
|
|
29
33
|
cwd = excluded.cwd,
|
|
30
34
|
title = COALESCE(excluded.title, session_state.title),
|
|
31
|
-
created_at = excluded.created_at
|
|
35
|
+
created_at = excluded.created_at,
|
|
36
|
+
project_sort = COALESCE(session_state.project_sort, excluded.project_sort)`).run(summary.id, pinned ? 1 : 0, summary.cwd, summary.title ?? null, summary.createdAt, sibling?.rank ?? null);
|
|
37
|
+
}
|
|
38
|
+
/** One drag = one write of the whole list it reordered: index is the place.
|
|
39
|
+
* `sessions` are ids (a session's place inside its project), `projects` are
|
|
40
|
+
* cwds, whose place is stamped on every session that has that cwd. */
|
|
41
|
+
reorder(order) {
|
|
42
|
+
const bySession = this.#db.prepare("UPDATE session_state SET sort = ? WHERE session_id = ?");
|
|
43
|
+
const byCwd = this.#db.prepare("UPDATE session_state SET project_sort = ? WHERE cwd = ?");
|
|
44
|
+
this.#tx(() => {
|
|
45
|
+
order.sessions?.forEach((id, i) => bySession.run(i, id));
|
|
46
|
+
order.projects?.forEach((cwd, i) => byCwd.run(i, cwd));
|
|
47
|
+
});
|
|
32
48
|
}
|
|
33
49
|
/** Project rows only; unlike AgentFactory.list(), this never touches disk. */
|
|
34
50
|
projects() {
|
|
35
|
-
const rows = this.#db.prepare(`SELECT session_id AS id, cwd, title, created_at AS createdAt, unread
|
|
51
|
+
const rows = this.#db.prepare(`SELECT session_id AS id, cwd, title, created_at AS createdAt, unread,
|
|
52
|
+
sort, project_sort AS projectSort
|
|
36
53
|
FROM session_state
|
|
37
54
|
WHERE pinned = 1 AND cwd IS NOT NULL AND created_at IS NOT NULL
|
|
38
55
|
ORDER BY created_at DESC`).all();
|
|
39
|
-
return rows.map(({ title, unread, ...row }) => ({
|
|
56
|
+
return rows.map(({ title, unread, sort, projectSort, ...row }) => ({
|
|
40
57
|
...row,
|
|
41
58
|
...(title ? { title } : {}),
|
|
59
|
+
...(sort === null ? {} : { sort }),
|
|
60
|
+
...(projectSort === null ? {} : { projectSort }),
|
|
42
61
|
unread: unread === 1,
|
|
43
62
|
}));
|
|
44
63
|
}
|
|
64
|
+
/** What to call a session where there is room for one line — a push
|
|
65
|
+
* notification's title. Falls back to the project directory, then to the
|
|
66
|
+
* fact that it is a session at all: a notification with no title reads as a
|
|
67
|
+
* browser bug rather than as an unnamed session. */
|
|
68
|
+
name(sessionId) {
|
|
69
|
+
const row = this.#db.prepare("SELECT title, cwd FROM session_state WHERE session_id = ?").get(sessionId);
|
|
70
|
+
return row?.title || row?.cwd?.split("/").filter(Boolean).at(-1) || "Pier session";
|
|
71
|
+
}
|
|
45
72
|
needsProjectBackfill() {
|
|
46
73
|
return this.#db.prepare("SELECT 1 FROM session_state WHERE pinned = 1 AND (cwd IS NULL OR created_at IS NULL) LIMIT 1").get() !== undefined;
|
|
47
74
|
}
|
|
@@ -50,10 +77,16 @@ export class SessionStateStore {
|
|
|
50
77
|
const update = this.#db.prepare(`UPDATE session_state SET
|
|
51
78
|
cwd = ?, title = COALESCE(?, title), created_at = ?
|
|
52
79
|
WHERE session_id = ?`);
|
|
53
|
-
this.#
|
|
54
|
-
try {
|
|
80
|
+
this.#tx(() => {
|
|
55
81
|
for (const s of summaries)
|
|
56
82
|
update.run(s.cwd, s.title ?? null, s.createdAt, s.id);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** All-or-nothing: a half-written order is a list nobody arranged. */
|
|
86
|
+
#tx(run) {
|
|
87
|
+
this.#db.exec("BEGIN");
|
|
88
|
+
try {
|
|
89
|
+
run();
|
|
57
90
|
this.#db.exec("COMMIT");
|
|
58
91
|
}
|
|
59
92
|
catch (err) {
|
|
@@ -62,8 +95,14 @@ export class SessionStateStore {
|
|
|
62
95
|
}
|
|
63
96
|
}
|
|
64
97
|
flags() {
|
|
65
|
-
const rows = this.#db.prepare(
|
|
66
|
-
|
|
98
|
+
const rows = this.#db.prepare(`SELECT session_id AS id, pinned, unread, sort, project_sort AS projectSort
|
|
99
|
+
FROM session_state WHERE pinned = 1 OR unread = 1`).all();
|
|
100
|
+
return new Map(rows.map((r) => [r.id, {
|
|
101
|
+
pinned: r.pinned === 1,
|
|
102
|
+
unread: r.unread === 1,
|
|
103
|
+
...(r.sort === null ? {} : { sort: r.sort }),
|
|
104
|
+
...(r.projectSort === null ? {} : { projectSort: r.projectSort }),
|
|
105
|
+
}]));
|
|
67
106
|
}
|
|
68
107
|
/** The first prompt supplies the title of a newly-created pinned session. */
|
|
69
108
|
title(sessionId, text) {
|
package/dist/web/terminal.js
CHANGED
|
@@ -28,6 +28,15 @@ const MAX_FRAME_BYTES = 1024 * 1024;
|
|
|
28
28
|
// start under. Inheriting these can attach the shell back into Pier's parent
|
|
29
29
|
// tmux session; SSH_AUTH_SOCK deliberately stays so git/ssh keep working.
|
|
30
30
|
const PARENT_TERMINAL_ENV = ["TMUX", "TMUX_PANE", "SSH_TTY", "SSH_CLIENT", "SSH_CONNECTION"];
|
|
31
|
+
// Pier's own configuration is Pier's, not this shell's. `NODE_ENV=production`
|
|
32
|
+
// alone turns an `npm i` typed here into an install with no dev dependencies,
|
|
33
|
+
// and every `PI_*`/`PIER_*` would point a `pi` started here at Pier's own
|
|
34
|
+
// instance rather than the person's. Everything else is inherited on purpose:
|
|
35
|
+
// PATH, LANG, SSH_AUTH_SOCK and the session's XDG/DBUS handles are what make
|
|
36
|
+
// the shell usable, and on a service-managed instance nothing else supplies
|
|
37
|
+
// them.
|
|
38
|
+
const PIER_OWN_ENV = ["NODE_ENV", "PORT", "HOST"];
|
|
39
|
+
const PIER_OWN_PREFIX = /^PI(ER)?_/;
|
|
31
40
|
const send = (sock, data) => {
|
|
32
41
|
try {
|
|
33
42
|
sock.send(data);
|
|
@@ -44,6 +53,7 @@ export class TerminalHub {
|
|
|
44
53
|
#shell;
|
|
45
54
|
#idleMs;
|
|
46
55
|
#maxTerms;
|
|
56
|
+
#initCommand;
|
|
47
57
|
#sweeper;
|
|
48
58
|
#closeListeners = new Set();
|
|
49
59
|
#closed = false;
|
|
@@ -51,6 +61,7 @@ export class TerminalHub {
|
|
|
51
61
|
this.#shell = opts.shell ?? process.env.SHELL ?? "/bin/bash";
|
|
52
62
|
this.#idleMs = opts.idleMs ?? IDLE_MS;
|
|
53
63
|
this.#maxTerms = opts.maxTerms ?? MAX_TERMS;
|
|
64
|
+
this.#initCommand = opts.initCommand ?? (() => "");
|
|
54
65
|
this.#sweeper = setInterval(() => this.sweep(Date.now()), SWEEP_MS);
|
|
55
66
|
this.#sweeper.unref();
|
|
56
67
|
}
|
|
@@ -117,8 +128,11 @@ export class TerminalHub {
|
|
|
117
128
|
}
|
|
118
129
|
#spawn(cwd) {
|
|
119
130
|
const env = { ...process.env };
|
|
120
|
-
for (const key of
|
|
121
|
-
|
|
131
|
+
for (const key of Object.keys(env)) {
|
|
132
|
+
if (PARENT_TERMINAL_ENV.includes(key) || PIER_OWN_ENV.includes(key) || PIER_OWN_PREFIX.test(key)) {
|
|
133
|
+
delete env[key];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
122
136
|
const pty = spawn(this.#shell, [], {
|
|
123
137
|
name: "xterm-256color",
|
|
124
138
|
cols: 120,
|
|
@@ -129,6 +143,14 @@ export class TerminalHub {
|
|
|
129
143
|
const term = { pty, ring: [], ringBytes: 0, clients: new Set(), idleSince: Infinity };
|
|
130
144
|
this.#terms.set(cwd, term);
|
|
131
145
|
log.info(`shell ${pty.pid} for ${cwd}`);
|
|
146
|
+
// Typed in, not exec'd: the shell stays the parent, so quitting whatever
|
|
147
|
+
// this starts leaves a usable prompt, and the echo plus any error is in
|
|
148
|
+
// the ring where the person can see what ran. The tty buffers it until the
|
|
149
|
+
// shell's first read, so no wait is needed. A reattach never repeats it —
|
|
150
|
+
// this runs once per pty, which is once per cwd.
|
|
151
|
+
const init = this.#initCommand().trim();
|
|
152
|
+
if (init)
|
|
153
|
+
pty.write(`${init}\r`);
|
|
132
154
|
pty.onData((data) => {
|
|
133
155
|
const chunk = Buffer.from(data);
|
|
134
156
|
term.ring.push(chunk);
|
|
@@ -179,6 +201,13 @@ export class TerminalHub {
|
|
|
179
201
|
term.pty.write(msg.d);
|
|
180
202
|
return;
|
|
181
203
|
}
|
|
204
|
+
// The only way a page can end a shell: everything attached to it is told by
|
|
205
|
+
// the exit path below, exactly as if the shell had exited on its own.
|
|
206
|
+
if (msg.t === "restart") {
|
|
207
|
+
log.info(`shell ${term.pty.pid} for ${cwd} killed on request`);
|
|
208
|
+
term.pty.kill();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
182
211
|
if (msg.t === "resize" &&
|
|
183
212
|
typeof msg.cols === "number" && typeof msg.rows === "number" &&
|
|
184
213
|
Number.isInteger(msg.cols) && Number.isInteger(msg.rows) &&
|
|
@@ -225,8 +254,9 @@ export class TerminalHub {
|
|
|
225
254
|
/** The upgrade seam: `/api/terminal?cwd=…` behind the same password boundary
|
|
226
255
|
* as every route. SameSite=Lax already withholds the cookie cross-site; the
|
|
227
256
|
* Origin check is the explicit copy of that fact. */
|
|
228
|
-
export function attachTerminal(server, auth,
|
|
229
|
-
const
|
|
257
|
+
export function attachTerminal(server, auth, opts = {}) {
|
|
258
|
+
const heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
|
|
259
|
+
const hub = new TerminalHub(opts);
|
|
230
260
|
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES });
|
|
231
261
|
const alive = new WeakSet();
|
|
232
262
|
const heartbeat = setInterval(() => {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The Web Push wire format, and only that: RFC 8291 message encryption
|
|
2
|
+
// (ECDH P-256 → HKDF → one aes128gcm record) and RFC 8292 VAPID
|
|
3
|
+
// authorization. Who is notified, and why, is push.ts.
|
|
4
|
+
//
|
|
5
|
+
// Written on node:crypto instead of pulled in: the whole format is one ECDH,
|
|
6
|
+
// two HKDFs, one AES-GCM record and a JWT, and every step of it has a
|
|
7
|
+
// published test vector (webpush.test.ts runs the RFC's). A dependency here
|
|
8
|
+
// would be new transitive code inside the process that holds the operator's
|
|
9
|
+
// provider keys, buying ~120 lines (principle 8).
|
|
10
|
+
import { createCipheriv, createECDH, createPrivateKey, hkdfSync, randomBytes, sign } from "node:crypto";
|
|
11
|
+
/** RFC 8291 §4: one record, and a push service need not accept more than 4096
|
|
12
|
+
* octets of body. Header (86) + padding (1) + GCM tag (16) leaves this. */
|
|
13
|
+
export const MAX_PUSH_PLAINTEXT = 3993;
|
|
14
|
+
const RECORD_SIZE = 4096;
|
|
15
|
+
/** VAPID token lifetime. Apple refuses anything past 24h; half a day is well
|
|
16
|
+
* inside every push service's limit and still outlives a slow retry. */
|
|
17
|
+
const TOKEN_TTL_S = 12 * 60 * 60;
|
|
18
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
19
|
+
const b64 = (b) => Buffer.from(b instanceof ArrayBuffer ? new Uint8Array(b) : b).toString("base64url");
|
|
20
|
+
const unb64 = (s) => Buffer.from(s, "base64url");
|
|
21
|
+
/** A P-256 scalar is 32 octets; OpenSSL hands back the minimal encoding, so a
|
|
22
|
+
* key with a zero high byte is one octet short of what JWK accepts. */
|
|
23
|
+
const pad32 = (b) => b.length >= 32 ? b : Buffer.concat([Buffer.alloc(32 - b.length), b]);
|
|
24
|
+
export function generateVapidKeys() {
|
|
25
|
+
const ecdh = createECDH("prime256v1");
|
|
26
|
+
ecdh.generateKeys();
|
|
27
|
+
return {
|
|
28
|
+
publicKey: b64(ecdh.getPublicKey()),
|
|
29
|
+
privateKey: b64(pad32(ecdh.getPrivateKey())),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Encrypt one push message for `target` (RFC 8291 §3.4, RFC 8188 header).
|
|
34
|
+
*
|
|
35
|
+
* `salt` and `serverKey` are injectable for exactly one reason: the RFC's
|
|
36
|
+
* worked example is the only way to prove this implementation is right, and it
|
|
37
|
+
* fixes both. Nothing else may pass them — a reused salt is a broken cipher.
|
|
38
|
+
*/
|
|
39
|
+
export function encryptPush(plaintext, target, { salt = randomBytes(16), serverKey } = {}) {
|
|
40
|
+
const body = Buffer.from(plaintext);
|
|
41
|
+
if (body.length > MAX_PUSH_PLAINTEXT) {
|
|
42
|
+
throw new Error(`push payload is ${String(body.length)} bytes, over ${String(MAX_PUSH_PLAINTEXT)}`);
|
|
43
|
+
}
|
|
44
|
+
const uaPublic = unb64(target.p256dh);
|
|
45
|
+
const ecdh = createECDH("prime256v1");
|
|
46
|
+
// computeSecret() rejects a point that is not on the curve, which is the
|
|
47
|
+
// validation RFC 8291 §7 asks for before a private key touches it.
|
|
48
|
+
if (serverKey)
|
|
49
|
+
ecdh.setPrivateKey(serverKey);
|
|
50
|
+
else
|
|
51
|
+
ecdh.generateKeys();
|
|
52
|
+
const asPublic = ecdh.getPublicKey();
|
|
53
|
+
const shared = ecdh.computeSecret(uaPublic);
|
|
54
|
+
const keyInfo = Buffer.concat([Buffer.from("WebPush: info\0"), uaPublic, asPublic]);
|
|
55
|
+
const ikm = Buffer.from(hkdfSync("sha256", shared, unb64(target.auth), keyInfo, 32));
|
|
56
|
+
const cek = Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16));
|
|
57
|
+
const nonce = Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: nonce\0"), 12));
|
|
58
|
+
const cipher = createCipheriv("aes-128-gcm", cek, nonce);
|
|
59
|
+
// 0x02 is the padding delimiter of the last (here: only) record.
|
|
60
|
+
const sealed = Buffer.concat([
|
|
61
|
+
cipher.update(Buffer.concat([body, Buffer.of(2)])),
|
|
62
|
+
cipher.final(),
|
|
63
|
+
cipher.getAuthTag(),
|
|
64
|
+
]);
|
|
65
|
+
const header = Buffer.alloc(21);
|
|
66
|
+
salt.copy(header);
|
|
67
|
+
header.writeUInt32BE(RECORD_SIZE, 16);
|
|
68
|
+
header.writeUInt8(asPublic.length, 20);
|
|
69
|
+
return Buffer.concat([header, asPublic, sealed]);
|
|
70
|
+
}
|
|
71
|
+
/** The `Authorization` a push service checks before it accepts anything: a
|
|
72
|
+
* short-lived ES256 JWT bound to the service's own origin, plus the public
|
|
73
|
+
* key the subscription was created with (RFC 8292 §3). */
|
|
74
|
+
export function vapidAuthorization(endpoint, keys, subject, now = Date.now()) {
|
|
75
|
+
const token = [
|
|
76
|
+
b64(Buffer.from(JSON.stringify({ typ: "JWT", alg: "ES256" }))),
|
|
77
|
+
b64(Buffer.from(JSON.stringify({
|
|
78
|
+
aud: new URL(endpoint).origin,
|
|
79
|
+
exp: Math.floor(now / 1000) + TOKEN_TTL_S,
|
|
80
|
+
sub: subject,
|
|
81
|
+
}))),
|
|
82
|
+
].join(".");
|
|
83
|
+
const pub = unb64(keys.publicKey);
|
|
84
|
+
const key = createPrivateKey({
|
|
85
|
+
format: "jwk",
|
|
86
|
+
key: {
|
|
87
|
+
kty: "EC",
|
|
88
|
+
crv: "P-256",
|
|
89
|
+
x: b64(pub.subarray(1, 33)),
|
|
90
|
+
y: b64(pub.subarray(33, 65)),
|
|
91
|
+
d: b64(pad32(unb64(keys.privateKey))),
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
// JOSE wants the raw r||s pair; node's default for EC keys is DER.
|
|
95
|
+
const signature = sign("sha256", Buffer.from(token), { key, dsaEncoding: "ieee-p1363" });
|
|
96
|
+
return `vapid t=${token}.${b64(signature)}, k=${keys.publicKey}`;
|
|
97
|
+
}
|
|
98
|
+
/** POST one encrypted message. Never throws: every outcome is a result the
|
|
99
|
+
* caller can log or act on. */
|
|
100
|
+
export async function sendPush(target, payload, keys, subject, ttlSeconds = 4 * 60 * 60, fetchImpl = fetch) {
|
|
101
|
+
let body;
|
|
102
|
+
try {
|
|
103
|
+
body = encryptPush(payload, target);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
return { status: 0, error: `encrypting failed: ${String(err)}` };
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetchImpl(target.endpoint, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: {
|
|
112
|
+
authorization: vapidAuthorization(target.endpoint, keys, subject),
|
|
113
|
+
"content-encoding": "aes128gcm",
|
|
114
|
+
"content-type": "application/octet-stream",
|
|
115
|
+
ttl: String(ttlSeconds),
|
|
116
|
+
urgency: "normal",
|
|
117
|
+
},
|
|
118
|
+
body: new Uint8Array(body),
|
|
119
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
120
|
+
});
|
|
121
|
+
if (res.ok)
|
|
122
|
+
return { status: res.status };
|
|
123
|
+
// The service's own sentence is the only thing that explains a 400 from
|
|
124
|
+
// Apple or a 403 from FCM; without it the operator sees a bare number.
|
|
125
|
+
const said = (await res.text().catch(() => "")).slice(0, 200);
|
|
126
|
+
return { status: res.status, error: said || res.statusText };
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
return { status: 0, error: String(err) };
|
|
130
|
+
}
|
|
131
|
+
}
|
package/package.json
CHANGED
|
@@ -71,6 +71,18 @@ operator's source of truth.
|
|
|
71
71
|
(Telegram ~3.8k chars); the footer and the next-step buttons ride the last
|
|
72
72
|
one.
|
|
73
73
|
|
|
74
|
+
## Notifications on the web
|
|
75
|
+
|
|
76
|
+
- The workbench can push a notification when a turn finishes and no client
|
|
77
|
+
had that session on screen — Settings → Instance → Notifications, per
|
|
78
|
+
browser. Chrome and Edge on desktop work in a tab; **iPhone and iPad only
|
|
79
|
+
notify the installed app**, so it is Share → Add to Home Screen first, then
|
|
80
|
+
enable it from the icon's window. A "Send a test notification" button in the
|
|
81
|
+
same card answers whether it actually arrives.
|
|
82
|
+
- Pier is installable (an **Install Pier** button appears in that same card on
|
|
83
|
+
Chrome and Edge; elsewhere it is the address-bar icon), and an installed
|
|
84
|
+
Pier badges its icon with the number of sessions carrying an unread turn.
|
|
85
|
+
|
|
74
86
|
## Who may talk (groups and binding)
|
|
75
87
|
|
|
76
88
|
- Group messages pass a per-chat gate the operator sets: it can require a
|