pinomad 0.7.0 → 0.9.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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  serveWebClient
4
- } from "./chunk-r6w5aqkw.js";
4
+ } from "./chunk-wb10efyq.js";
5
5
  import {
6
6
  RELAY_CLOSE,
7
7
  decodeHostToRelay,
@@ -9,14 +9,14 @@ import {
9
9
  relayWsBase,
10
10
  verifyRelayChallenge,
11
11
  verifyRelayChallengeV2
12
- } from "./chunk-7qb9tq54.js";
12
+ } from "./chunk-khcpv54k.js";
13
13
  import {
14
14
  commandHint,
15
15
  webRoot
16
- } from "./chunk-rtwxjb90.js";
16
+ } from "./chunk-vyvyaavr.js";
17
17
  import {
18
18
  normalizeHostName
19
- } from "./chunk-znhn8af7.js";
19
+ } from "./chunk-sygeb9qb.js";
20
20
  import"./chunk-df32avw6.js";
21
21
 
22
22
  // apps/server/src/main.ts
@@ -57,9 +57,19 @@ async function lockDataDir(dir, options) {
57
57
 
58
58
  // apps/server/src/accounts.ts
59
59
  var DB_FILE = "server.sqlite";
60
- var SCHEMA_VERSION = 1;
60
+ var SCHEMA_VERSION = 3;
61
61
  var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
62
62
  var JOIN_TOKEN_TTL_MS = 10 * 60 * 1000;
63
+ var INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
64
+ var SIGN_UP_USERNAME = /^[a-z0-9][a-z0-9._-]{1,31}$/;
65
+ var PUSH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
66
+ var MAX_PUSH_TOKENS_PER_USER = 32;
67
+ var APNS_TOKEN_PATTERN = /^(?:[0-9a-f]{2})+$/;
68
+ var APNS_TOKEN_MAX_CHARS = 256;
69
+ function normalizeApnsToken(raw) {
70
+ const token = raw.toLowerCase();
71
+ return token.length <= APNS_TOKEN_MAX_CHARS && APNS_TOKEN_PATTERN.test(token) ? token : undefined;
72
+ }
63
73
  var SCRYPT = { N: 16384, r: 8, p: 1 };
64
74
  var KEY_LEN = 32;
65
75
  var SALT_LEN = 16;
@@ -117,6 +127,45 @@ function migrate(db) {
117
127
  }
118
128
  if (version === SCHEMA_VERSION)
119
129
  return;
130
+ if (version < 1)
131
+ migrateTo1(db);
132
+ if (version < 2) {
133
+ db.exec(`
134
+ BEGIN;
135
+ CREATE TABLE push_tokens (
136
+ token TEXT NOT NULL,
137
+ environment TEXT NOT NULL,
138
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
139
+ updated_at INTEGER NOT NULL,
140
+ PRIMARY KEY (token, environment)
141
+ );
142
+ CREATE INDEX push_tokens_user ON push_tokens (user_id, updated_at);
143
+ PRAGMA user_version = 2;
144
+ COMMIT;
145
+ `);
146
+ }
147
+ if (version < 3) {
148
+ db.exec(`
149
+ BEGIN;
150
+ ALTER TABLE users ADD COLUMN admin INTEGER NOT NULL DEFAULT 0;
151
+ UPDATE users SET admin = 1 WHERE id = (SELECT MIN(id) FROM users);
152
+ -- AUTOINCREMENT: minting sweeps expired invites, and a reused id would
153
+ -- let a revoke from a stale list hit a code already sent to someone.
154
+ CREATE TABLE invites (
155
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
156
+ code_hash TEXT NOT NULL UNIQUE,
157
+ created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
158
+ created_at INTEGER NOT NULL,
159
+ expires_at INTEGER NOT NULL,
160
+ used_at INTEGER,
161
+ used_by INTEGER REFERENCES users(id) ON DELETE SET NULL
162
+ );
163
+ PRAGMA user_version = 3;
164
+ COMMIT;
165
+ `);
166
+ }
167
+ }
168
+ function migrateTo1(db) {
120
169
  db.exec(`
121
170
  BEGIN;
122
171
  CREATE TABLE users (
@@ -146,7 +195,7 @@ function migrate(db) {
146
195
  joined_at INTEGER NOT NULL,
147
196
  last_seen_at INTEGER NOT NULL
148
197
  );
149
- PRAGMA user_version = ${SCHEMA_VERSION};
198
+ PRAGMA user_version = 1;
150
199
  COMMIT;
151
200
  `);
152
201
  }
@@ -237,7 +286,7 @@ async function openAccounts(options) {
237
286
  assertUsernamePolicy(options.bootstrapUser);
238
287
  assertPasswordPolicy(options.bootstrapPassword);
239
288
  const hash = await hashPassword(options.bootstrapPassword);
240
- db.prepare("INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)").run(options.bootstrapUser, hash, now());
289
+ db.prepare("INSERT INTO users (username, password_hash, created_at, admin) VALUES (?, ?, ?, 1)").run(options.bootstrapUser, hash, now());
241
290
  }
242
291
  } catch (error) {
243
292
  db?.close();
@@ -256,6 +305,20 @@ async function openAccounts(options) {
256
305
  const gate = new ScryptGate(limits.scryptConcurrency, limits.scryptQueue);
257
306
  const ipAttempts = new WindowedCounter(limits.counterMaxEntries, now);
258
307
  const userFailures = new WindowedCounter(limits.counterMaxEntries, now);
308
+ const issueSession = (userId) => {
309
+ db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(now());
310
+ const token = randomBytes(32).toString("base64url");
311
+ const expiresAt = now() + SESSION_TTL_MS;
312
+ db.prepare("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(sha256Hex(token), userId, now(), expiresAt);
313
+ return { token, expiresAt };
314
+ };
315
+ const inviteState = (codeHash) => {
316
+ const row = db.prepare("SELECT expires_at AS expiresAt, used_at AS usedAt FROM invites WHERE code_hash = ?").get(codeHash);
317
+ if (row === undefined || row.usedAt !== null)
318
+ return "invalid_invite";
319
+ return row.expiresAt <= now() ? "invite_expired" : "ok";
320
+ };
321
+ const usernameTaken = (username) => db.prepare("SELECT 1 AS x FROM users WHERE lower(username) = ?").get(username) !== undefined;
259
322
  return {
260
323
  dbPath: join(options.dataDir, DB_FILE),
261
324
  async login(username, password, ip) {
@@ -263,7 +326,7 @@ async function openAccounts(options) {
263
326
  return { status: "limited" };
264
327
  if (userFailures.isOver(username, limits.userMaxFailures))
265
328
  return { status: "limited" };
266
- const user = db.prepare("SELECT id, username, password_hash AS passwordHash FROM users WHERE username = ?").get(username);
329
+ const user = db.prepare("SELECT id, username, password_hash AS passwordHash, admin FROM users WHERE username = ?").get(username);
267
330
  const outcome = await gate.run(async () => {
268
331
  if (userFailures.isOver(username, limits.userMaxFailures))
269
332
  return "limited";
@@ -279,28 +342,123 @@ async function openAccounts(options) {
279
342
  if (outcome === "invalid")
280
343
  return { status: "invalid" };
281
344
  userFailures.reset(username);
282
- db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(now());
283
- const token = randomBytes(32).toString("base64url");
284
- const expiresAt = now() + SESSION_TTL_MS;
285
- db.prepare("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(sha256Hex(token), outcome, now(), expiresAt);
286
- return { status: "ok", token, expiresAt };
345
+ return { status: "ok", ...issueSession(outcome), admin: user?.admin === 1 };
346
+ },
347
+ async register({ invite, username, password, ip }) {
348
+ if (ipAttempts.hit(ip, limits.ipMaxAttempts, limits.ipWindowMs))
349
+ return { status: "limited" };
350
+ const codeHash = sha256Hex(invite);
351
+ const invited = inviteState(codeHash);
352
+ if (invited !== "ok")
353
+ return { status: "refused", reason: invited };
354
+ if (!SIGN_UP_USERNAME.test(username))
355
+ return { status: "refused", reason: "invalid_username" };
356
+ const length = [...password].length;
357
+ if (length < MIN_PASSWORD_CODEPOINTS || length > MAX_PASSWORD_CODEPOINTS) {
358
+ return { status: "refused", reason: "weak_password" };
359
+ }
360
+ if (usernameTaken(username))
361
+ return { status: "refused", reason: "username_taken" };
362
+ const hash = await gate.run(() => hashPassword(password));
363
+ if (hash === undefined)
364
+ return { status: "limited" };
365
+ db.exec("BEGIN");
366
+ try {
367
+ const again = inviteState(codeHash);
368
+ const failure = again !== "ok" ? again : usernameTaken(username) ? "username_taken" : undefined;
369
+ if (failure !== undefined) {
370
+ db.exec("ROLLBACK");
371
+ return { status: "refused", reason: failure };
372
+ }
373
+ const userId = Number(db.prepare("INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)").run(username, hash, now()).lastInsertRowid);
374
+ const claimed = db.prepare("UPDATE invites SET used_at = ?, used_by = ? WHERE code_hash = ? AND used_at IS NULL").run(now(), userId, codeHash);
375
+ if (Number(claimed.changes) !== 1) {
376
+ db.exec("ROLLBACK");
377
+ return { status: "refused", reason: "invalid_invite" };
378
+ }
379
+ const session = issueSession(userId);
380
+ db.exec("COMMIT");
381
+ return { status: "ok", ...session };
382
+ } catch (error) {
383
+ db.exec("ROLLBACK");
384
+ throw error;
385
+ }
386
+ },
387
+ createInvite(userId) {
388
+ db.prepare("DELETE FROM invites WHERE used_at IS NULL AND expires_at <= ?").run(now());
389
+ const code = randomBytes(16).toString("base64url");
390
+ const expiresAt = now() + INVITE_TTL_MS;
391
+ db.prepare("INSERT INTO invites (code_hash, created_by, created_at, expires_at) VALUES (?, ?, ?, ?)").run(sha256Hex(code), userId, now(), expiresAt);
392
+ return { code, expiresAt };
393
+ },
394
+ listInvites() {
395
+ const rows = db.prepare(`SELECT i.id AS id, i.created_at AS createdAt, i.expires_at AS expiresAt, i.used_at AS usedAt, u.username AS usedBy
396
+ FROM invites i LEFT JOIN users u ON u.id = i.used_by ORDER BY i.created_at DESC, i.id DESC`).all();
397
+ return rows.map((row) => ({
398
+ id: row.id,
399
+ createdAt: row.createdAt,
400
+ expiresAt: row.expiresAt,
401
+ ...row.usedAt === null ? {} : { usedAt: row.usedAt },
402
+ ...row.usedBy === null ? {} : { usedBy: row.usedBy }
403
+ }));
404
+ },
405
+ revokeInvite(id) {
406
+ return Number(db.prepare("DELETE FROM invites WHERE id = ? AND used_at IS NULL").run(id).changes) > 0;
287
407
  },
288
408
  authenticate(token) {
289
- const row = db.prepare(`SELECT s.user_id AS userId, u.username AS username, s.expires_at AS expiresAt
409
+ const row = db.prepare(`SELECT s.user_id AS userId, u.username AS username, u.admin AS admin, s.expires_at AS expiresAt
290
410
  FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?`).get(sha256Hex(token));
291
411
  if (row === undefined || row.expiresAt <= now())
292
412
  return;
293
- return { userId: row.userId, username: row.username };
413
+ return { userId: row.userId, username: row.username, admin: row.admin === 1 };
294
414
  },
295
415
  logout(token) {
296
416
  db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(sha256Hex(token));
297
417
  },
298
418
  revokeAllSessions(userId) {
299
- db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
419
+ db.exec("BEGIN");
420
+ try {
421
+ db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
422
+ db.prepare("DELETE FROM push_tokens WHERE user_id = ?").run(userId);
423
+ db.exec("COMMIT");
424
+ } catch (error) {
425
+ db.exec("ROLLBACK");
426
+ throw error;
427
+ }
300
428
  },
301
429
  hasHost(hostId) {
302
430
  return db.prepare("SELECT 1 AS x FROM hosts WHERE host_id = ?").get(hostId) !== undefined;
303
431
  },
432
+ hostOwner(hostId) {
433
+ const row = db.prepare("SELECT user_id AS userId FROM hosts WHERE host_id = ?").get(hostId);
434
+ return row?.userId;
435
+ },
436
+ registerPushToken(userId, token, environment) {
437
+ const at = now();
438
+ db.exec("BEGIN");
439
+ try {
440
+ db.prepare("DELETE FROM push_tokens WHERE updated_at < ?").run(at - PUSH_TOKEN_TTL_MS);
441
+ db.prepare(`INSERT INTO push_tokens (token, environment, user_id, updated_at) VALUES (?, ?, ?, ?)
442
+ ON CONFLICT (token, environment) DO UPDATE SET user_id = excluded.user_id, updated_at = excluded.updated_at`).run(token.toLowerCase(), environment, userId, at);
443
+ db.prepare(`DELETE FROM push_tokens WHERE user_id = ? AND rowid NOT IN (
444
+ SELECT rowid FROM push_tokens WHERE user_id = ? ORDER BY updated_at DESC LIMIT ?
445
+ )`).run(userId, userId, MAX_PUSH_TOKENS_PER_USER);
446
+ db.exec("COMMIT");
447
+ } catch (error) {
448
+ db.exec("ROLLBACK");
449
+ throw error;
450
+ }
451
+ },
452
+ removePushToken(userId, token, environment) {
453
+ db.prepare("DELETE FROM push_tokens WHERE token = ? AND environment = ? AND user_id = ?").run(token.toLowerCase(), environment, userId);
454
+ },
455
+ pushTokenOwner(token, environment) {
456
+ const row = db.prepare("SELECT user_id AS userId FROM push_tokens WHERE token = ? AND environment = ? AND updated_at >= ?").get(token.toLowerCase(), environment, now() - PUSH_TOKEN_TTL_MS);
457
+ return row?.userId;
458
+ },
459
+ forgetPushToken(token, environment, unregisteredAt) {
460
+ db.prepare("DELETE FROM push_tokens WHERE token = ? AND environment = ? AND updated_at < ?").run(token.toLowerCase(), environment, unregisteredAt);
461
+ },
304
462
  createJoinToken(userId) {
305
463
  db.prepare("DELETE FROM join_tokens WHERE expires_at <= ?").run(now());
306
464
  const token = randomBytes(32).toString("base64url");
@@ -389,6 +547,7 @@ async function changePassword(options) {
389
547
  db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hash, userId);
390
548
  db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
391
549
  db.prepare("DELETE FROM join_tokens WHERE user_id = ? AND used_at IS NULL").run(userId);
550
+ db.prepare("DELETE FROM push_tokens WHERE user_id = ?").run(userId);
392
551
  db.exec("COMMIT");
393
552
  } catch (error) {
394
553
  db.exec("ROLLBACK");
@@ -719,7 +878,7 @@ async function handleApiRequest(request, response, ctx) {
719
878
  return;
720
879
  }
721
880
  const bearer = (request.headers.authorization ?? "").startsWith("Bearer ");
722
- const loginTokenMode = pathname === "/api/login" && (origin === undefined || origin === DESKTOP_ORIGIN);
881
+ const loginTokenMode = (pathname === "/api/login" || pathname === "/api/register") && (origin === undefined || origin === DESKTOP_ORIGIN);
723
882
  if (MUTATING.has(method) && !bearer && !loginTokenMode && origin !== ctx.publicOrigin) {
724
883
  deny(403, "origin_mismatch");
725
884
  return;
@@ -754,9 +913,92 @@ async function handleApiRequest(request, response, ctx) {
754
913
  return;
755
914
  }
756
915
  const cookieMode = origin === ctx.publicOrigin;
916
+ send(200, {
917
+ user: { username },
918
+ ...result.admin ? { admin: true } : {},
919
+ expiresAt: result.expiresAt,
920
+ ...cookieMode ? {} : { token: result.token }
921
+ }, cookieMode ? { "set-cookie": setSessionCookie(result.token) } : undefined);
922
+ return;
923
+ }
924
+ if (pathname === "/api/register") {
925
+ if (method !== "POST") {
926
+ deny(405, "method_not_allowed", { allow: "POST" });
927
+ return;
928
+ }
929
+ const body = await readJson(request);
930
+ if (!body.ok) {
931
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
932
+ return;
933
+ }
934
+ const value = body.value;
935
+ if (typeof value?.invite !== "string" || typeof value.username !== "string" || typeof value.password !== "string") {
936
+ deny(400, "invalid_request");
937
+ return;
938
+ }
939
+ const { invite, username } = value;
940
+ const result = await accounts.register({
941
+ invite,
942
+ username,
943
+ password: value.password,
944
+ ip: clientIp(request, ctx.trustProxy)
945
+ });
946
+ if (result.status === "limited") {
947
+ deny(429, "rate_limited");
948
+ return;
949
+ }
950
+ if (result.status === "refused") {
951
+ const status = result.reason === "invalid_invite" || result.reason === "invite_expired" ? 403 : result.reason === "username_taken" ? 409 : 400;
952
+ deny(status, result.reason);
953
+ return;
954
+ }
955
+ const cookieMode = origin === ctx.publicOrigin;
757
956
  send(200, { user: { username }, expiresAt: result.expiresAt, ...cookieMode ? {} : { token: result.token } }, cookieMode ? { "set-cookie": setSessionCookie(result.token) } : undefined);
758
957
  return;
759
958
  }
959
+ if (pathname === "/api/invites" || pathname === "/api/invites/revoke") {
960
+ const allowed = pathname === "/api/invites" ? ["GET", "POST"] : ["POST"];
961
+ if (!allowed.includes(method)) {
962
+ deny(405, "method_not_allowed", { allow: allowed.join(", ") });
963
+ return;
964
+ }
965
+ const token = sessionToken(request);
966
+ const session = token === undefined ? undefined : accounts.authenticate(token);
967
+ if (session === undefined) {
968
+ deny(401, "unauthenticated");
969
+ return;
970
+ }
971
+ if (!session.admin) {
972
+ deny(403, "forbidden");
973
+ return;
974
+ }
975
+ if (pathname === "/api/invites") {
976
+ if (method === "GET")
977
+ send(200, accounts.listInvites());
978
+ else
979
+ send(200, accounts.createInvite(session.userId));
980
+ return;
981
+ }
982
+ const body = await readJson(request);
983
+ if (!body.ok) {
984
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
985
+ return;
986
+ }
987
+ const id = body.value?.id;
988
+ if (typeof id !== "number" || !Number.isSafeInteger(id)) {
989
+ deny(400, "invalid_request");
990
+ return;
991
+ }
992
+ if (accounts.authenticate(token)?.admin !== true) {
993
+ deny(401, "unauthenticated");
994
+ return;
995
+ }
996
+ if (accounts.revokeInvite(id))
997
+ send(204);
998
+ else
999
+ deny(404, "not_found");
1000
+ return;
1001
+ }
760
1002
  const hostPairing = /^\/api\/hosts\/([A-Za-z0-9_-]{43})\/pairing$/.exec(pathname);
761
1003
  if (pathname === "/api/hosts" || hostPairing !== null) {
762
1004
  const expected = pathname === "/api/hosts" ? "GET" : "POST";
@@ -810,7 +1052,8 @@ async function handleApiRequest(request, response, ctx) {
810
1052
  url: `${relayWsBase(ctx.publicOrigin)}/c/${row.hostId}`,
811
1053
  hostKey: row.noisePublicKey,
812
1054
  secret: result.secret,
813
- expiresAt: result.expiresAt
1055
+ expiresAt: result.expiresAt,
1056
+ ...result.endpointSet === undefined ? {} : { endpointSet: result.endpointSet }
814
1057
  });
815
1058
  return;
816
1059
  case "refused":
@@ -828,7 +1071,7 @@ async function handleApiRequest(request, response, ctx) {
828
1071
  }
829
1072
  return;
830
1073
  }
831
- if (pathname === "/api/me" || pathname === "/api/logout" || pathname === "/api/sessions/revoke-all" || pathname === "/api/join-tokens") {
1074
+ if (pathname === "/api/me" || pathname === "/api/logout" || pathname === "/api/sessions/revoke-all" || pathname === "/api/join-tokens" || pathname === "/api/push-tokens" || pathname === "/api/push-tokens/remove") {
832
1075
  const expected = pathname === "/api/me" ? "GET" : "POST";
833
1076
  if (method !== expected) {
834
1077
  deny(405, "method_not_allowed", { allow: expected });
@@ -841,7 +1084,31 @@ async function handleApiRequest(request, response, ctx) {
841
1084
  return;
842
1085
  }
843
1086
  if (pathname === "/api/me") {
844
- send(200, { user: { username: session.username } });
1087
+ send(200, { user: { username: session.username }, ...session.admin ? { admin: true } : {} });
1088
+ return;
1089
+ }
1090
+ if (pathname === "/api/push-tokens" || pathname === "/api/push-tokens/remove") {
1091
+ const body = await readJson(request);
1092
+ if (!body.ok) {
1093
+ deny(body.tooLarge ? 413 : 400, body.tooLarge ? "payload_too_large" : "invalid_json");
1094
+ return;
1095
+ }
1096
+ const value = body.value;
1097
+ const apns = typeof value?.apns === "string" ? normalizeApnsToken(value.apns) : undefined;
1098
+ const environment = value?.environment;
1099
+ if (apns === undefined || environment !== "production" && environment !== "development") {
1100
+ deny(400, "invalid_request");
1101
+ return;
1102
+ }
1103
+ if (accounts.authenticate(token) === undefined) {
1104
+ deny(401, "unauthenticated");
1105
+ return;
1106
+ }
1107
+ if (pathname === "/api/push-tokens")
1108
+ accounts.registerPushToken(session.userId, apns, environment);
1109
+ else
1110
+ accounts.removePushToken(session.userId, apns, environment);
1111
+ send(204);
845
1112
  return;
846
1113
  }
847
1114
  if (pathname === "/api/join-tokens") {
@@ -984,7 +1251,8 @@ async function startRelay(options) {
984
1251
  socket.send(encodeRelayFrame({ t: "push-result", requestId: frame.requestId, ...verdict }));
985
1252
  }
986
1253
  };
987
- if (accounts?.hasHost(hostId) !== true) {
1254
+ const owner = accounts?.hostOwner(hostId);
1255
+ if (accounts === undefined || owner === undefined) {
988
1256
  finish({ result: "rejected", reason: "unauthorized" });
989
1257
  return;
990
1258
  }
@@ -992,6 +1260,10 @@ async function startRelay(options) {
992
1260
  finish({ result: "unavailable", reason: "environment not configured" });
993
1261
  return;
994
1262
  }
1263
+ if (accounts.pushTokenOwner(frame.token, frame.environment) !== owner) {
1264
+ finish({ result: "rejected", reason: "token_not_registered" });
1265
+ return;
1266
+ }
995
1267
  const perHost = pushInflightHosts.get(hostId) ?? 0;
996
1268
  if (perHost >= pushPerHost || pushInflightTotal >= pushTotal) {
997
1269
  finish({ result: "retry", reason: "rate_limited" });
@@ -999,7 +1271,12 @@ async function startRelay(options) {
999
1271
  }
1000
1272
  pushInflightTotal += 1;
1001
1273
  pushInflightHosts.set(hostId, perHost + 1);
1002
- apns.send({ token: frame.token, environment: frame.environment, notification: frame.notification }).then(finish, () => finish({ result: "retry", reason: "server error" })).finally(() => {
1274
+ apns.send({ token: frame.token, environment: frame.environment, notification: frame.notification }).then((verdict) => {
1275
+ if (verdict.result === "unregistered") {
1276
+ accounts.forgetPushToken(frame.token, frame.environment, verdict.unregisteredAt);
1277
+ }
1278
+ finish(verdict);
1279
+ }, () => finish({ result: "retry", reason: "server error" })).finally(() => {
1003
1280
  pushInflightTotal -= 1;
1004
1281
  const left = (pushInflightHosts.get(hostId) ?? 1) - 1;
1005
1282
  if (left <= 0)
@@ -1073,7 +1350,12 @@ async function startRelay(options) {
1073
1350
  const request = pairingRequests.get(answer.requestId);
1074
1351
  if (request === undefined || request.control !== socket)
1075
1352
  return;
1076
- request.settle(answer.t === "pairing" ? { status: "ok", secret: answer.secret, expiresAt: answer.expiresAt } : { status: "refused", reason: answer.reason });
1353
+ request.settle(answer.t === "pairing" ? {
1354
+ status: "ok",
1355
+ secret: answer.secret,
1356
+ expiresAt: answer.expiresAt,
1357
+ ...answer.endpointSet === undefined ? {} : { endpointSet: answer.endpointSet }
1358
+ } : { status: "refused", reason: answer.reason });
1077
1359
  return;
1078
1360
  }
1079
1361
  socket.close(1008, "unexpected frame");
@@ -1,6 +1,6 @@
1
1
  // apps/host/src/distribution.ts
2
2
  import { fileURLToPath } from "node:url";
3
- var packagedVersion = "0.7.0";
3
+ var packagedVersion = "0.9.0";
4
4
  var webRoot = fileURLToPath(new URL(packagedVersion === undefined ? "../../web/dist" : "../web/", import.meta.url));
5
5
  var hostEntry = packagedVersion === undefined ? { script: fileURLToPath(new URL("./main.ts", import.meta.url)), args: [] } : { script: fileURLToPath(new URL("./pinomad.js", import.meta.url)), args: ["host"] };
6
6
  var commandHint = (name) => packagedVersion === undefined ? `bun run ${name}` : `pinomad ${name}`;
@@ -13,7 +13,24 @@ var WEB_TYPES = {
13
13
  ".woff2": "font/woff2"
14
14
  };
15
15
  var NOT_BUILT = "Web client not built: run bun run build";
16
+ var SECURITY_HEADERS = {
17
+ "content-security-policy": [
18
+ "default-src 'self'",
19
+ "script-src 'self'",
20
+ "style-src 'self' 'unsafe-inline'",
21
+ "img-src 'self' data:",
22
+ "connect-src 'self' ws: wss:",
23
+ "object-src 'none'",
24
+ "base-uri 'self'",
25
+ "form-action 'self'",
26
+ "frame-ancestors 'none'"
27
+ ].join("; "),
28
+ "x-content-type-options": "nosniff",
29
+ "referrer-policy": "no-referrer"
30
+ };
16
31
  async function serveWebClient(request, response, webRoot) {
32
+ for (const [name, value] of Object.entries(SECURITY_HEADERS))
33
+ response.setHeader(name, value);
17
34
  if (request.method !== "GET" && request.method !== "HEAD") {
18
35
  response.writeHead(405).end();
19
36
  return;
@@ -3,37 +3,37 @@ import {
3
3
  assertSecureServerOrigin,
4
4
  probeServerAccounts,
5
5
  runServerOperation
6
- } from "./chunk-ywmm63ks.js";
6
+ } from "./chunk-3p1eysm9.js";
7
7
  import {
8
8
  constantValue,
9
9
  openHostSettings
10
- } from "./chunk-b1h374jr.js";
10
+ } from "./chunk-v414b5pp.js";
11
11
  import {
12
12
  loadHostKey,
13
13
  loadRelayKey,
14
14
  writeServerConfig
15
- } from "./chunk-0xh6cy9v.js";
15
+ } from "./chunk-5gp9svzs.js";
16
16
  import {
17
17
  relayHostId
18
- } from "./chunk-7qb9tq54.js";
18
+ } from "./chunk-khcpv54k.js";
19
19
  import {
20
20
  argValue,
21
21
  installSpec,
22
22
  restartService,
23
23
  serviceManager,
24
24
  withoutRelayArgs
25
- } from "./chunk-12kaxggg.js";
25
+ } from "./chunk-76pvsg2d.js";
26
26
  import"./chunk-kzbzacz9.js";
27
27
  import {
28
28
  commandHint,
29
29
  hostEntry
30
- } from "./chunk-rtwxjb90.js";
31
- import"./chunk-sj7rnvh4.js";
32
- import"./chunk-e4ahjbmz.js";
30
+ } from "./chunk-vyvyaavr.js";
31
+ import"./chunk-4sepcfpy.js";
32
+ import"./chunk-jgd38rna.js";
33
33
  import {
34
34
  cleanHostName,
35
35
  normalizeHostName
36
- } from "./chunk-znhn8af7.js";
36
+ } from "./chunk-sygeb9qb.js";
37
37
  import {
38
38
  DEFAULT_DATA_DIR
39
39
  } from "./chunk-ngf1zatm.js";
package/dist/pinomad.js CHANGED
@@ -5,22 +5,22 @@ import {
5
5
  } from "./chunk-kzbzacz9.js";
6
6
  import {
7
7
  packagedVersion
8
- } from "./chunk-rtwxjb90.js";
8
+ } from "./chunk-vyvyaavr.js";
9
9
  import {
10
10
  __require
11
11
  } from "./chunk-df32avw6.js";
12
12
 
13
13
  // apps/cli/src/pinomad.ts
14
14
  var COMMANDS = {
15
- host: () => import("./chunk-1nyzr0mg.js"),
16
- server: () => import("./chunk-6ysezfy5.js"),
17
- relay: () => import("./chunk-6ysezfy5.js"),
18
- service: () => import("./chunk-1t58fh0k.js"),
19
- upgrade: () => import("./chunk-sz3rzk55.js"),
20
- link: () => import("./chunk-8yme2wep.js"),
21
- pair: () => import("./chunk-m4c5gvce.js"),
22
- join: () => import("./chunk-fbkd73zr.js"),
23
- leave: () => import("./chunk-07769pqx.js")
15
+ host: () => import("./chunk-ctzgjctt.js"),
16
+ server: () => import("./chunk-vxmj0200.js"),
17
+ relay: () => import("./chunk-vxmj0200.js"),
18
+ service: () => import("./chunk-m824ds0q.js"),
19
+ upgrade: () => import("./chunk-qgexy01f.js"),
20
+ link: () => import("./chunk-014b7ne7.js"),
21
+ pair: () => import("./chunk-vax4zspw.js"),
22
+ join: () => import("./chunk-yhf7bj5q.js"),
23
+ leave: () => import("./chunk-0tesv2sr.js")
24
24
  };
25
25
  var USAGE = `pinomad — PiNomad host, server, and utilities
26
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinomad",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "PiNomad — self-hosted coding agent: host, relay, and web client in one package",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -20,20 +20,24 @@
20
20
  },
21
21
  "homepage": "https://github.com/bubbleptr/pinomad",
22
22
  "dependencies": {
23
- "@earendil-works/chord": "1.2.0",
24
- "@earendil-works/pi-ai": "1.2.0",
25
- "@earendil-works/pi-codemode": "1.2.0",
26
- "@earendil-works/pi-coding-agent": "1.2.0",
27
- "@earendil-works/pi-durable": "1.2.0",
28
- "@earendil-works/pi-mcp": "1.2.0",
23
+ "@earendil-works/chord": "1.4.0",
24
+ "@earendil-works/pi-ai": "1.4.0",
25
+ "@earendil-works/pi-codemode": "1.4.0",
26
+ "@earendil-works/pi-coding-agent": "1.4.0",
27
+ "@earendil-works/pi-durable": "1.4.0",
28
+ "@earendil-works/pi-mcp": "1.4.0",
29
29
  "@noble/curves": "2.4.0",
30
30
  "diff": "8.0.4",
31
+ "node-datachannel": "0.33.4",
31
32
  "proper-lockfile": "4.1.2",
32
33
  "typebox": "1.3.27",
33
34
  "undici": "8.10.2",
34
35
  "uqr": "0.1.3",
35
36
  "ws": "8.22.0",
36
37
  "@noble/ciphers": "2.4.0",
37
- "@noble/hashes": "2.4.0"
38
+ "@noble/hashes": "2.4.0",
39
+ "@earendil-works/pi-agent-core": "1.4.0",
40
+ "@earendil-works/pi-telemetry": "1.4.0",
41
+ "@earendil-works/pi-tui": "1.4.0"
38
42
  }
39
43
  }