pinomad 0.8.0 → 0.9.1

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 CHANGED
@@ -26,6 +26,8 @@ pinomad link # in another terminal: prints a browser link to the web clie
26
26
 
27
27
  Sign in to a model provider under Settings → Providers: a subscription you already pay for (Claude, ChatGPT, Copilot, …), an API key, or your own OpenAI- or Anthropic-compatible endpoint. To click around without one, `pinomad host --faux "hi"` runs a scripted fake model.
28
28
 
29
+ To reach the host from your Mac and iPhone anywhere without running a server, [use the official server](https://pinomad.si/docs/official-server/) at app.pinomad.si.
30
+
29
31
  ## Documentation
30
32
 
31
33
  User docs live on **[pinomad.si/docs](https://pinomad.si/docs/)**: [getting started](https://pinomad.si/docs/) (connecting your other devices, keeping the host running, clients) and [what the agent can do](https://pinomad.si/docs/features/). Usage, remote access, and the service reference are in Chinese for now: [中文文档](https://pinomad.si/zh/docs/).
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  packagedVersion
3
- } from "./chunk-p56xf1r8.js";
3
+ } from "./chunk-fqgvf8v0.js";
4
4
  import {
5
5
  hostAddress
6
6
  } from "./chunk-ngf1zatm.js";
@@ -5,11 +5,11 @@ import {
5
5
  commandHint,
6
6
  hostEntry,
7
7
  packagedVersion
8
- } from "./chunk-p56xf1r8.js";
8
+ } from "./chunk-fqgvf8v0.js";
9
9
  import {
10
10
  ProtocolMismatchError,
11
11
  connectRemoteDurable
12
- } from "./chunk-a4b61tx5.js";
12
+ } from "./chunk-4sepcfpy.js";
13
13
  import {
14
14
  DEFAULT_DATA_DIR,
15
15
  hostAddress
@@ -2,10 +2,10 @@
2
2
  import {
3
3
  main,
4
4
  restartService
5
- } from "./chunk-73vxwjs7.js";
5
+ } from "./chunk-32g3m2h0.js";
6
6
  import"./chunk-kzbzacz9.js";
7
- import"./chunk-p56xf1r8.js";
8
- import"./chunk-a4b61tx5.js";
7
+ import"./chunk-fqgvf8v0.js";
8
+ import"./chunk-4sepcfpy.js";
9
9
  import"./chunk-jgd38rna.js";
10
10
  import"./chunk-sygeb9qb.js";
11
11
  import"./chunk-ngf1zatm.js";
@@ -2022,6 +2022,8 @@ class RemoteClient {
2022
2022
  }
2023
2023
  }
2024
2024
  #snapshot(stream, endSatisfies = false) {
2025
+ if (!this.#wanted.has(stream))
2026
+ return Promise.resolve();
2025
2027
  return new Promise((resolve, reject) => {
2026
2028
  const waiters = this.#snapshotWaiters.get(stream) ?? [];
2027
2029
  waiters.push({ resolve, reject, endSatisfies });
@@ -2044,9 +2046,10 @@ class RemoteClient {
2044
2046
  async#acquire(id) {
2045
2047
  const missing = this.#conversationStreams(id).filter((stream) => !this.#wanted.has(stream));
2046
2048
  const stale = this.#conversationStreams(id).filter((stream) => this.#wanted.has(stream) && !this.#fresh.has(stream));
2049
+ for (const stream of missing)
2050
+ this.#wanted.add(stream);
2047
2051
  const shown = Promise.all([...missing, ...stale].map((stream) => this.#snapshot(stream)));
2048
2052
  for (const stream of missing) {
2049
- this.#wanted.add(stream);
2050
2053
  if (this.#helloReceived)
2051
2054
  this.#subscribe(stream);
2052
2055
  }
@@ -2060,6 +2063,8 @@ class RemoteClient {
2060
2063
  this.#wanted.delete(stream);
2061
2064
  this.#values.delete(stream);
2062
2065
  this.#fresh.delete(stream);
2066
+ for (const waiter of this.#snapshotWaiters.get(stream)?.splice(0) ?? [])
2067
+ waiter.resolve();
2063
2068
  this.#send({ type: "unsubscribe", stream });
2064
2069
  }
2065
2070
  }
@@ -2814,6 +2819,7 @@ class RemoteClient {
2814
2819
  }),
2815
2820
  switchConversation: (id) => this.#command(() => this.#switch(id)),
2816
2821
  showSide: (id) => this.#command(() => this.#showSide(id)),
2822
+ clearConversation: () => this.#command(() => this.#clear()),
2817
2823
  fork: (entryId, prompt, removeTools, options) => this.#command(async () => {
2818
2824
  const { conversationId: forked } = await this.#call("fork", {
2819
2825
  conversationId: conversationId(),
@@ -2956,6 +2962,10 @@ class RemoteClient {
2956
2962
  this.#release(previous, this.#side);
2957
2963
  this.#refresh();
2958
2964
  }
2965
+ async#clear() {
2966
+ this.#unshow();
2967
+ await this.#showSide(undefined);
2968
+ }
2959
2969
  async#showSide(id) {
2960
2970
  const previous = this.#side;
2961
2971
  if (id === previous)
@@ -15,7 +15,8 @@ var DevicesDoc = defineDoc({
15
15
  kind: "pinomad.devices",
16
16
  version: 1,
17
17
  scope: "session",
18
- initial: () => ({ devices: [] })
18
+ initial: () => ({ devices: [] }),
19
+ checkpointWhen: () => false
19
20
  });
20
21
  async function ensureDevices(harness, context) {
21
22
  await harness.commit(async (tx) => void await tx.doc(DevicesDoc), context);
@@ -24,7 +25,8 @@ var RevocationsDoc = defineDoc({
24
25
  kind: "pinomad.revocations",
25
26
  version: 1,
26
27
  scope: "session",
27
- initial: () => ({ revoked: [] })
28
+ initial: () => ({ revoked: [] }),
29
+ checkpointWhen: () => false
28
30
  });
29
31
  async function admitDevice(harness, input, context) {
30
32
  return await harness.commit(async (tx) => {
@@ -2,14 +2,14 @@
2
2
  import {
3
3
  restartService,
4
4
  serviceManager
5
- } from "./chunk-73vxwjs7.js";
5
+ } from "./chunk-32g3m2h0.js";
6
6
  import"./chunk-kzbzacz9.js";
7
7
  import {
8
8
  globalPrefixOf,
9
9
  packageDir,
10
10
  packagedVersion
11
- } from "./chunk-p56xf1r8.js";
12
- import"./chunk-a4b61tx5.js";
11
+ } from "./chunk-fqgvf8v0.js";
12
+ import"./chunk-4sepcfpy.js";
13
13
  import"./chunk-jgd38rna.js";
14
14
  import"./chunk-sygeb9qb.js";
15
15
  import {
@@ -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,
@@ -13,7 +13,7 @@ import {
13
13
  import {
14
14
  commandHint,
15
15
  webRoot
16
- } from "./chunk-p56xf1r8.js";
16
+ } from "./chunk-fqgvf8v0.js";
17
17
  import {
18
18
  normalizeHostName
19
19
  } from "./chunk-sygeb9qb.js";
@@ -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";
@@ -829,7 +1071,7 @@ async function handleApiRequest(request, response, ctx) {
829
1071
  }
830
1072
  return;
831
1073
  }
832
- 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") {
833
1075
  const expected = pathname === "/api/me" ? "GET" : "POST";
834
1076
  if (method !== expected) {
835
1077
  deny(405, "method_not_allowed", { allow: expected });
@@ -842,7 +1084,31 @@ async function handleApiRequest(request, response, ctx) {
842
1084
  return;
843
1085
  }
844
1086
  if (pathname === "/api/me") {
845
- 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);
846
1112
  return;
847
1113
  }
848
1114
  if (pathname === "/api/join-tokens") {
@@ -985,7 +1251,8 @@ async function startRelay(options) {
985
1251
  socket.send(encodeRelayFrame({ t: "push-result", requestId: frame.requestId, ...verdict }));
986
1252
  }
987
1253
  };
988
- if (accounts?.hasHost(hostId) !== true) {
1254
+ const owner = accounts?.hostOwner(hostId);
1255
+ if (accounts === undefined || owner === undefined) {
989
1256
  finish({ result: "rejected", reason: "unauthorized" });
990
1257
  return;
991
1258
  }
@@ -993,6 +1260,10 @@ async function startRelay(options) {
993
1260
  finish({ result: "unavailable", reason: "environment not configured" });
994
1261
  return;
995
1262
  }
1263
+ if (accounts.pushTokenOwner(frame.token, frame.environment) !== owner) {
1264
+ finish({ result: "rejected", reason: "token_not_registered" });
1265
+ return;
1266
+ }
996
1267
  const perHost = pushInflightHosts.get(hostId) ?? 0;
997
1268
  if (perHost >= pushPerHost || pushInflightTotal >= pushTotal) {
998
1269
  finish({ result: "retry", reason: "rate_limited" });
@@ -1000,7 +1271,12 @@ async function startRelay(options) {
1000
1271
  }
1001
1272
  pushInflightTotal += 1;
1002
1273
  pushInflightHosts.set(hostId, perHost + 1);
1003
- 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(() => {
1004
1280
  pushInflightTotal -= 1;
1005
1281
  const left = (pushInflightHosts.get(hostId) ?? 1) - 1;
1006
1282
  if (left <= 0)
@@ -1,6 +1,6 @@
1
1
  // apps/host/src/distribution.ts
2
2
  import { fileURLToPath } from "node:url";
3
- var packagedVersion = "0.8.0";
3
+ var packagedVersion = "0.9.1";
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}`;
@@ -12,7 +12,7 @@ import {
12
12
  loadHostKey,
13
13
  loadRelayKey,
14
14
  writeServerConfig
15
- } from "./chunk-r2ssa2ex.js";
15
+ } from "./chunk-5gp9svzs.js";
16
16
  import {
17
17
  relayHostId
18
18
  } from "./chunk-khcpv54k.js";
@@ -22,13 +22,13 @@ import {
22
22
  restartService,
23
23
  serviceManager,
24
24
  withoutRelayArgs
25
- } from "./chunk-73vxwjs7.js";
25
+ } from "./chunk-32g3m2h0.js";
26
26
  import"./chunk-kzbzacz9.js";
27
27
  import {
28
28
  commandHint,
29
29
  hostEntry
30
- } from "./chunk-p56xf1r8.js";
31
- import"./chunk-a4b61tx5.js";
30
+ } from "./chunk-fqgvf8v0.js";
31
+ import"./chunk-4sepcfpy.js";
32
32
  import"./chunk-jgd38rna.js";
33
33
  import {
34
34
  cleanHostName,