@timqi/pier 0.0.7 → 0.0.8

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.
@@ -42,6 +42,14 @@ export class SessionStateStore {
42
42
  unread: unread === 1,
43
43
  }));
44
44
  }
45
+ /** What to call a session where there is room for one line — a push
46
+ * notification's title. Falls back to the project directory, then to the
47
+ * fact that it is a session at all: a notification with no title reads as a
48
+ * browser bug rather than as an unnamed session. */
49
+ name(sessionId) {
50
+ const row = this.#db.prepare("SELECT title, cwd FROM session_state WHERE session_id = ?").get(sessionId);
51
+ return row?.title || row?.cwd?.split("/").filter(Boolean).at(-1) || "Pier session";
52
+ }
45
53
  needsProjectBackfill() {
46
54
  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
55
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timqi/pier",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "A self-hosted workspace for coding agents: web workbench and IM channels in front of Pi sessions",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": "github:timqi/pier",
@@ -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