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.
- package/README.md +7 -58
- package/dist/{chunk-8yme2wep.js → chunk-014b7ne7.js} +1 -1
- package/dist/{chunk-07769pqx.js → chunk-0tesv2sr.js} +8 -8
- package/dist/{chunk-ywmm63ks.js → chunk-3p1eysm9.js} +4 -2
- package/dist/chunk-4sepcfpy.js +2994 -0
- package/dist/{chunk-0xh6cy9v.js → chunk-5gp9svzs.js} +5 -3
- package/dist/{chunk-12kaxggg.js → chunk-76pvsg2d.js} +2 -2
- package/dist/{chunk-1nyzr0mg.js → chunk-ctzgjctt.js} +2395 -839
- package/dist/{chunk-e4ahjbmz.js → chunk-jgd38rna.js} +210 -7
- package/dist/{chunk-7qb9tq54.js → chunk-khcpv54k.js} +64 -310
- package/dist/{chunk-1t58fh0k.js → chunk-m824ds0q.js} +5 -5
- package/dist/{chunk-sz3rzk55.js → chunk-qgexy01f.js} +5 -5
- package/dist/chunk-sygeb9qb.js +430 -0
- package/dist/{chunk-b1h374jr.js → chunk-v414b5pp.js} +8 -2
- package/dist/{chunk-m4c5gvce.js → chunk-vax4zspw.js} +22 -5
- package/dist/{chunk-6ysezfy5.js → chunk-vxmj0200.js} +305 -23
- package/dist/{chunk-rtwxjb90.js → chunk-vyvyaavr.js} +1 -1
- package/dist/{chunk-r6w5aqkw.js → chunk-wb10efyq.js} +17 -0
- package/dist/{chunk-fbkd73zr.js → chunk-yhf7bj5q.js} +9 -9
- package/dist/pinomad.js +10 -10
- package/package.json +12 -8
- package/web/assets/index-1g60bhv2.js +395 -0
- package/web/assets/{index-C0rwAo0H.css → index-DoyHMPwf.css} +1 -1
- package/web/index.html +2 -2
- package/dist/chunk-sj7rnvh4.js +0 -719
- package/dist/chunk-znhn8af7.js +0 -33
- package/web/assets/index-DGee2kNU.js +0 -394
|
@@ -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,
|
|
@@ -9,14 +9,14 @@ import {
|
|
|
9
9
|
relayWsBase,
|
|
10
10
|
verifyRelayChallenge,
|
|
11
11
|
verifyRelayChallengeV2
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-khcpv54k.js";
|
|
13
13
|
import {
|
|
14
14
|
commandHint,
|
|
15
15
|
webRoot
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-vyvyaavr.js";
|
|
17
17
|
import {
|
|
18
18
|
normalizeHostName
|
|
19
|
-
} from "./chunk-
|
|
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 =
|
|
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";
|
|
@@ -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
|
-
|
|
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(
|
|
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" ? {
|
|
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.
|
|
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-
|
|
6
|
+
} from "./chunk-3p1eysm9.js";
|
|
7
7
|
import {
|
|
8
8
|
constantValue,
|
|
9
9
|
openHostSettings
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-v414b5pp.js";
|
|
11
11
|
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
|
-
} from "./chunk-
|
|
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-
|
|
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-
|
|
32
|
-
import"./chunk-
|
|
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-
|
|
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-
|
|
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,20 +20,24 @@
|
|
|
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
|
+
"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
|
}
|