pinomad 0.8.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.
- package/README.md +2 -0
- package/dist/{chunk-p15hqqjz.js → chunk-014b7ne7.js} +1 -1
- package/dist/{chunk-ddh6wfv2.js → chunk-0tesv2sr.js} +4 -4
- package/dist/{chunk-a4b61tx5.js → chunk-4sepcfpy.js} +11 -1
- package/dist/{chunk-r2ssa2ex.js → chunk-5gp9svzs.js} +4 -2
- package/dist/{chunk-73vxwjs7.js → chunk-76pvsg2d.js} +2 -2
- package/dist/{chunk-jjtr7fr5.js → chunk-ctzgjctt.js} +89 -26
- package/dist/{chunk-ptjyk94f.js → chunk-m824ds0q.js} +3 -3
- package/dist/{chunk-fes7wjw1.js → chunk-qgexy01f.js} +3 -3
- package/dist/{chunk-mv94ksvb.js → chunk-vax4zspw.js} +1 -1
- package/dist/{chunk-1r8a2434.js → chunk-vxmj0200.js} +295 -19
- package/dist/{chunk-p56xf1r8.js → chunk-vyvyaavr.js} +1 -1
- package/dist/{chunk-r6w5aqkw.js → chunk-wb10efyq.js} +17 -0
- package/dist/{chunk-cytnycs9.js → chunk-yhf7bj5q.js} +4 -4
- package/dist/pinomad.js +10 -10
- package/package.json +11 -8
- package/web/assets/index-1g60bhv2.js +395 -0
- package/web/assets/{index-D6qIPhPy.css → index-DoyHMPwf.css} +1 -1
- package/web/index.html +2 -2
- package/web/assets/index-5DfEA9Zb.js +0 -398
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
serveWebClient
|
|
4
|
-
} from "./chunk-
|
|
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-
|
|
16
|
+
} from "./chunk-vyvyaavr.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 =
|
|
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 =
|
|
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 (?, ?,
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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;
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
loadHostKey,
|
|
13
13
|
loadRelayKey,
|
|
14
14
|
writeServerConfig
|
|
15
|
-
} from "./chunk-
|
|
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-
|
|
25
|
+
} from "./chunk-76pvsg2d.js";
|
|
26
26
|
import"./chunk-kzbzacz9.js";
|
|
27
27
|
import {
|
|
28
28
|
commandHint,
|
|
29
29
|
hostEntry
|
|
30
|
-
} from "./chunk-
|
|
31
|
-
import"./chunk-
|
|
30
|
+
} from "./chunk-vyvyaavr.js";
|
|
31
|
+
import"./chunk-4sepcfpy.js";
|
|
32
32
|
import"./chunk-jgd38rna.js";
|
|
33
33
|
import {
|
|
34
34
|
cleanHostName,
|
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-
|
|
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-
|
|
16
|
-
server: () => import("./chunk-
|
|
17
|
-
relay: () => import("./chunk-
|
|
18
|
-
service: () => import("./chunk-
|
|
19
|
-
upgrade: () => import("./chunk-
|
|
20
|
-
link: () => import("./chunk-
|
|
21
|
-
pair: () => import("./chunk-
|
|
22
|
-
join: () => import("./chunk-
|
|
23
|
-
leave: () => import("./chunk-
|
|
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.
|
|
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,12 +20,12 @@
|
|
|
20
20
|
},
|
|
21
21
|
"homepage": "https://github.com/bubbleptr/pinomad",
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@earendil-works/chord": "1.
|
|
24
|
-
"@earendil-works/pi-ai": "1.
|
|
25
|
-
"@earendil-works/pi-codemode": "1.
|
|
26
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
27
|
-
"@earendil-works/pi-durable": "1.
|
|
28
|
-
"@earendil-works/pi-mcp": "1.
|
|
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
31
|
"node-datachannel": "0.33.4",
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
"uqr": "0.1.3",
|
|
36
36
|
"ws": "8.22.0",
|
|
37
37
|
"@noble/ciphers": "2.4.0",
|
|
38
|
-
"@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"
|
|
39
42
|
}
|
|
40
43
|
}
|