workoffdesk 0.0.0-stage → 1.0.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.
Files changed (128) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -2
  4. package/SECURITY.md +50 -0
  5. package/dist/build.json +1 -0
  6. package/dist/server/agents/claude/accounts.js +105 -0
  7. package/dist/server/agents/claude/hooks/asks.js +135 -0
  8. package/dist/server/agents/claude/hooks/hooks.js +114 -0
  9. package/dist/server/agents/claude/hooks/status.js +34 -0
  10. package/dist/server/agents/claude/hooks/tracker.js +79 -0
  11. package/dist/server/agents/claude/offers/commands.js +219 -0
  12. package/dist/server/agents/claude/offers/files.js +51 -0
  13. package/dist/server/agents/claude/offers/initialize.js +59 -0
  14. package/dist/server/agents/claude/offers/models.js +20 -0
  15. package/dist/server/agents/claude/screen/choices.js +189 -0
  16. package/dist/server/agents/claude/screen/compacting.js +20 -0
  17. package/dist/server/agents/claude/screen/live.js +63 -0
  18. package/dist/server/agents/claude/screen/panel.js +140 -0
  19. package/dist/server/agents/claude/screen/prompt.js +23 -0
  20. package/dist/server/agents/claude/screen/screen.js +79 -0
  21. package/dist/server/agents/claude/terminals.js +110 -0
  22. package/dist/server/agents/claude/transcript/chat.js +254 -0
  23. package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
  24. package/dist/server/agents/claude/transcript/transcripts.js +210 -0
  25. package/dist/server/agents/claude/transcript/turn.js +136 -0
  26. package/dist/server/agents/claude/transcript/watch.js +60 -0
  27. package/dist/server/agents/claude/usage.js +36 -0
  28. package/dist/server/agents/codex/agent.js +263 -0
  29. package/dist/server/agents/codex/appServer.js +38 -0
  30. package/dist/server/agents/codex/items.js +226 -0
  31. package/dist/server/agents/codex/notifications.js +192 -0
  32. package/dist/server/agents/codex/rpc.js +121 -0
  33. package/dist/server/agents/codex/screen.js +5 -0
  34. package/dist/server/agents/codex/threads.js +208 -0
  35. package/dist/server/agents/types.js +1 -0
  36. package/dist/server/cli.js +49 -0
  37. package/dist/server/commands/account.js +61 -0
  38. package/dist/server/commands/attach.js +27 -0
  39. package/dist/server/commands/common.js +114 -0
  40. package/dist/server/commands/doctor.js +43 -0
  41. package/dist/server/commands/install.js +169 -0
  42. package/dist/server/commands/open.js +31 -0
  43. package/dist/server/commands/qr.js +33 -0
  44. package/dist/server/commands/serve.js +45 -0
  45. package/dist/server/commands/service.js +87 -0
  46. package/dist/server/commands/setup.js +36 -0
  47. package/dist/server/commands/setupWeb.js +87 -0
  48. package/dist/server/commands/status.js +32 -0
  49. package/dist/server/commands/wizard/probe.js +19 -0
  50. package/dist/server/commands/wizard/server.js +162 -0
  51. package/dist/server/http/app.js +144 -0
  52. package/dist/server/http/auth/guard.js +78 -0
  53. package/dist/server/http/auth/lockout.js +41 -0
  54. package/dist/server/http/auth/passkey.js +151 -0
  55. package/dist/server/http/auth/password.js +23 -0
  56. package/dist/server/http/auth/sessions.js +46 -0
  57. package/dist/server/http/routes/appUpdate.js +97 -0
  58. package/dist/server/http/routes/auth.js +79 -0
  59. package/dist/server/http/routes/chat.js +216 -0
  60. package/dist/server/http/routes/claude.js +69 -0
  61. package/dist/server/http/routes/codex.js +58 -0
  62. package/dist/server/http/routes/context.js +1 -0
  63. package/dist/server/http/routes/health.js +56 -0
  64. package/dist/server/http/routes/journal.js +35 -0
  65. package/dist/server/http/routes/mac.js +39 -0
  66. package/dist/server/http/routes/media.js +83 -0
  67. package/dist/server/http/routes/push.js +78 -0
  68. package/dist/server/http/routes/sessions.js +104 -0
  69. package/dist/server/http/routes/web.js +28 -0
  70. package/dist/server/http/serve.js +129 -0
  71. package/dist/server/http/sessionSocket.js +198 -0
  72. package/dist/server/ios-update/appUpdate.js +95 -0
  73. package/dist/server/ios-update/bundlePatch.js +56 -0
  74. package/dist/server/ios-update/bundlePatch.worker.js +11 -0
  75. package/dist/server/journal/db.js +243 -0
  76. package/dist/server/journal/derive.js +50 -0
  77. package/dist/server/journal/index.js +256 -0
  78. package/dist/server/journal/ingest.js +151 -0
  79. package/dist/server/notify/apns.js +126 -0
  80. package/dist/server/notify/push.js +109 -0
  81. package/dist/server/ops/awake.js +10 -0
  82. package/dist/server/ops/build.js +40 -0
  83. package/dist/server/ops/doctor.js +110 -0
  84. package/dist/server/ops/lastOp.js +34 -0
  85. package/dist/server/ops/problems.js +54 -0
  86. package/dist/server/ops/release.js +206 -0
  87. package/dist/server/ops/report.js +40 -0
  88. package/dist/server/ops/service.js +39 -0
  89. package/dist/server/ops/shutdown.js +12 -0
  90. package/dist/server/sessions/control.js +97 -0
  91. package/dist/server/sessions/events.js +77 -0
  92. package/dist/server/sessions/machine/accounts.js +55 -0
  93. package/dist/server/sessions/machine/input.js +208 -0
  94. package/dist/server/sessions/machine/lifecycle.js +357 -0
  95. package/dist/server/sessions/machine/moves.js +71 -0
  96. package/dist/server/sessions/machine/state.js +71 -0
  97. package/dist/server/sessions/machine/status.js +257 -0
  98. package/dist/server/sessions/machine/types.js +2 -0
  99. package/dist/server/sessions/machine.js +300 -0
  100. package/dist/server/sessions/outbox.js +194 -0
  101. package/dist/server/sessions/panes.js +60 -0
  102. package/dist/server/sessions/sessionName.js +7 -0
  103. package/dist/server/sessions/terminalEnv.js +40 -0
  104. package/dist/server/system/config.js +71 -0
  105. package/dist/server/system/dirs.js +54 -0
  106. package/dist/server/system/errors.js +37 -0
  107. package/dist/server/system/log.js +40 -0
  108. package/dist/server/system/messages.js +378 -0
  109. package/dist/server/system/node.js +6 -0
  110. package/dist/server/system/paths.js +51 -0
  111. package/dist/server/system/store.js +43 -0
  112. package/dist/server/system/tmux.js +198 -0
  113. package/dist/server/system/version.js +16 -0
  114. package/dist/server/uploads/fileUpload.js +171 -0
  115. package/dist/server/uploads/media.js +139 -0
  116. package/dist/web/assets/Setup-DinzDcF1.js +1 -0
  117. package/dist/web/assets/main-C8GiLoJy.css +1 -0
  118. package/dist/web/assets/main-w7i8PvU7.js +132 -0
  119. package/dist/web/icon-180.png +0 -0
  120. package/dist/web/icon-192.png +0 -0
  121. package/dist/web/icon-512.png +0 -0
  122. package/dist/web/icon.svg +6 -0
  123. package/dist/web/index.html +25 -0
  124. package/dist/web/manifest.webmanifest +15 -0
  125. package/dist/web/sw.js +8 -0
  126. package/dist/web/theme.js +11 -0
  127. package/package.json +77 -4
  128. package/scripts/smoke.mjs +130 -0
@@ -0,0 +1,144 @@
1
+ import { appendFileSync } from 'node:fs';
2
+ import fastifyWebsocket from '@fastify/websocket';
3
+ import Fastify, { LogController } from 'fastify';
4
+ import { Guard, clientIp, sessionCookie } from './auth/guard.js';
5
+ import { LoginSessions } from './auth/sessions.js';
6
+ import { findTranscript, lastReply } from '../agents/claude/transcript/transcriptFile.js';
7
+ import { DomainError, errorReply } from '../system/errors.js';
8
+ import { EventHub } from '../sessions/events.js';
9
+ import { register as appUpdate } from './routes/appUpdate.js';
10
+ import { register as auth } from './routes/auth.js';
11
+ import { register as chat } from './routes/chat.js';
12
+ import { register as claude } from './routes/claude.js';
13
+ import { register as codex } from './routes/codex.js';
14
+ import { register as health } from './routes/health.js';
15
+ import { register as journal } from './routes/journal.js';
16
+ import { register as mac } from './routes/mac.js';
17
+ import { register as media } from './routes/media.js';
18
+ import { register as push } from './routes/push.js';
19
+ import { register as sessions } from './routes/sessions.js';
20
+ import { register as web } from './routes/web.js';
21
+ import { VERSION } from '../system/version.js';
22
+ const CSP = [
23
+ "default-src 'self'",
24
+ "connect-src 'self' wss: ws:",
25
+ "img-src 'self' data:",
26
+ "style-src 'self' 'unsafe-inline'",
27
+ "frame-ancestors 'none'",
28
+ "base-uri 'none'",
29
+ "form-action 'self'",
30
+ ].join('; ');
31
+ export async function buildApp(deps) {
32
+ const { store, machine, origin } = deps;
33
+ const now = deps.now ?? Date.now;
34
+ const app = Fastify({
35
+ trustProxy: false,
36
+ bodyLimit: 64 * 1024,
37
+ loggerInstance: deps.logger,
38
+ logController: new LogController({ disableRequestLogging: true }),
39
+ });
40
+ app.setErrorHandler((e, req, reply) => {
41
+ // fastify's own errors (a body that is not JSON, or too large) carry a status below 500.
42
+ const own = e;
43
+ if (!(e instanceof DomainError) && own.statusCode && own.statusCode < 500)
44
+ return reply.code(own.statusCode).send({ error: own.message });
45
+ const { status, body } = errorReply(e);
46
+ if (status >= 500)
47
+ req.log.error({ err: e }, 'request failed');
48
+ return reply.code(status).send(body);
49
+ });
50
+ const logins = new LoginSessions(store, now);
51
+ const guard = new Guard(logins, origin);
52
+ const secure = origin.startsWith('https://');
53
+ const hub = new EventHub(machine, async (s) => {
54
+ if (s.kind === 'codex')
55
+ return machine.lastReply(s.id);
56
+ const path = s.claudeSessionId && deps.claudeProjectsRoot ? await findTranscript(deps.claudeProjectsRoot, s.claudeSessionId) : null;
57
+ return path ? lastReply(path) : null;
58
+ });
59
+ const audit = (req, method, ok) => {
60
+ const line = { t: new Date(now()).toISOString(), ip: clientIp(req), ua: req.headers['user-agent'] ?? '', method, ok };
61
+ appendFileSync(deps.auditPath, `${JSON.stringify(line)}\n`, { mode: 0o600 });
62
+ };
63
+ const createLogin = (req, method) => logins.create(String(req.headers['user-agent'] ?? ''), method);
64
+ const startLogin = (req, reply, method) => {
65
+ reply.header('set-cookie', sessionCookie(createLogin(req, method), secure));
66
+ };
67
+ // An open socket lives only as long as the login it was opened with.
68
+ const sockets = new Map();
69
+ const dropRevokedSockets = () => {
70
+ for (const [socket, loginId] of sockets)
71
+ if (!logins.validate(loginId))
72
+ socket.close(4401, 'login ended');
73
+ };
74
+ const sweep = setInterval(dropRevokedSockets, 2000);
75
+ const admit = (socket, req) => {
76
+ const code = guard.wsRejectCode(req);
77
+ if (code) {
78
+ socket.close(code);
79
+ return false;
80
+ }
81
+ sockets.set(socket, guard.currentLogin(req).loginId);
82
+ socket.on('close', () => sockets.delete(socket));
83
+ return true;
84
+ };
85
+ await app.register(fastifyWebsocket, {
86
+ options: { maxPayload: 1 << 20 },
87
+ // On shutdown every socket is told 4012, "restarting", so the phone comes back soon instead of
88
+ // taking it for a dropped line. Not the standard 1012: Safari reports that one as 1005.
89
+ preClose: (done) => {
90
+ for (const socket of app.websocketServer.clients)
91
+ socket.close(4012, 'restarting');
92
+ app.websocketServer.close(done);
93
+ },
94
+ });
95
+ app.addHook('onSend', async (req, reply, payload) => {
96
+ reply.header('content-security-policy', CSP);
97
+ reply.header('x-frame-options', 'DENY');
98
+ reply.header('x-content-type-options', 'nosniff');
99
+ reply.header('referrer-policy', 'no-referrer');
100
+ reply.header('x-workoffdesk-version', VERSION);
101
+ if (req.routeOptions.url?.startsWith('/api/') && !reply.hasHeader('cache-control'))
102
+ reply.header('cache-control', 'no-store');
103
+ return payload;
104
+ });
105
+ // Only what someone would look for: a request that failed or took long, and every write, whose arrival is
106
+ // logged too, so one that never answers still shows. The route, not the URL, so nothing in a query string
107
+ // reaches the log.
108
+ app.addHook('onRequest', async (req) => {
109
+ if (req.method !== 'GET' && req.method !== 'HEAD')
110
+ req.log.info({ method: req.method, url: req.routeOptions.url ?? '(no route)' }, 'write arrived');
111
+ });
112
+ app.addHook('onResponse', async (req, reply) => {
113
+ const ms = Math.round(reply.elapsedTime);
114
+ if (reply.statusCode < 400 && ms <= 1000 && (req.method === 'GET' || req.method === 'HEAD'))
115
+ return;
116
+ req.log.info({ method: req.method, url: req.routeOptions.url ?? '(no route)', status: reply.statusCode, ms }, 'request');
117
+ });
118
+ app.addHook('onClose', async () => {
119
+ clearInterval(sweep);
120
+ hub.close();
121
+ });
122
+ // Every matched route requires a login unless it opts out with `config: { public: true }`.
123
+ // The decision reads the matched route, never the raw URL, which the router decodes first,
124
+ // and comes before the body is read. WebSocket routes answer with close codes instead (see Guard.wsRejectCode).
125
+ app.addHook('onRequest', async (req, reply) => {
126
+ if (req.ws || req.is404 || req.routeOptions.config.public)
127
+ return;
128
+ if (!guard.check(req, reply))
129
+ return reply;
130
+ });
131
+ const machineOr404 = (req, reply) => {
132
+ if (req.params.machine === machine.id)
133
+ return machine;
134
+ reply.code(404).send({ error: 'unknown machine' });
135
+ return null;
136
+ };
137
+ const ctx = { deps, guard, logins, hub, secure, now, audit, createLogin, startLogin, admit, dropRevokedSockets, machineOr404 };
138
+ for (const register of [health, auth, sessions, chat, journal, media, claude, codex, push, appUpdate, mac])
139
+ await register(app, ctx);
140
+ if (deps.extend)
141
+ await deps.extend(app, ctx);
142
+ await web(app, ctx);
143
+ return { app, ctx };
144
+ }
@@ -0,0 +1,78 @@
1
+ import { createHash } from 'node:crypto';
2
+ export const COOKIE = 'wod_session';
3
+ const BEARER = /^Bearer (\S+)$/i;
4
+ const MAX_AGE_SEC = 30 * 24 * 60 * 60;
5
+ export function parseCookies(header) {
6
+ const out = {};
7
+ for (const part of (header ?? '').split(';')) {
8
+ const i = part.indexOf('=');
9
+ if (i > 0)
10
+ out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
11
+ }
12
+ return out;
13
+ }
14
+ export function sessionCookie(id, secure) {
15
+ return [`${COOKIE}=${id}`, 'HttpOnly', 'SameSite=Strict', 'Path=/', `Max-Age=${MAX_AGE_SEC}`, ...(secure ? ['Secure'] : [])].join('; ');
16
+ }
17
+ export function clearedCookie(secure) {
18
+ return [`${COOKIE}=`, 'HttpOnly', 'SameSite=Strict', 'Path=/', 'Max-Age=0', ...(secure ? ['Secure'] : [])].join('; ');
19
+ }
20
+ export function loginHandle(id) {
21
+ return createHash('sha256').update(id).digest('base64url').slice(0, 16);
22
+ }
23
+ export function clientIp(req) {
24
+ const cf = req.headers['cf-connecting-ip'];
25
+ return (Array.isArray(cf) ? cf[0] : cf) || req.ip;
26
+ }
27
+ export class Guard {
28
+ logins;
29
+ origin;
30
+ constructor(logins, origin) {
31
+ this.logins = logins;
32
+ this.origin = origin;
33
+ }
34
+ originOk(req) {
35
+ return req.headers.origin === this.origin;
36
+ }
37
+ // The login a request claims, and whether it came in the cookie. A browser sends the cookie by itself to
38
+ // whatever page makes the request; the Authorization header only the app's own code puts there. Where
39
+ // the header is sent the cookie is not looked at, so a made-up header cannot carry a real cookie past
40
+ // the Origin check.
41
+ credential(req) {
42
+ const auth = req.headers.authorization;
43
+ if (auth !== undefined)
44
+ return { id: BEARER.exec(auth)?.[1] ?? '', byCookie: false };
45
+ const id = parseCookies(req.headers.cookie)[COOKIE];
46
+ return id ? { id, byCookie: true } : null;
47
+ }
48
+ // Whether another site could have had a browser make this request: only by riding on the cookie.
49
+ crossSiteSafe(req) {
50
+ return this.credential(req)?.byCookie === false || this.originOk(req);
51
+ }
52
+ currentLogin(req) {
53
+ const c = this.credential(req);
54
+ const login = c?.id ? this.logins.validate(c.id) : null;
55
+ return login ? { ...login, loginId: c.id } : null;
56
+ }
57
+ // Returns true when the request may proceed; otherwise it has already replied.
58
+ check(req, reply) {
59
+ if (req.method !== 'GET' && req.method !== 'HEAD' && !this.crossSiteSafe(req)) {
60
+ reply.code(403).send({ error: 'bad origin' });
61
+ return false;
62
+ }
63
+ if (!this.currentLogin(req)) {
64
+ reply.code(401).send({ error: 'login required' });
65
+ return false;
66
+ }
67
+ return true;
68
+ }
69
+ // WebSocket handshakes cannot surface an HTTP status to the browser, so the
70
+ // verdict is delivered as a close code after the upgrade completes.
71
+ wsRejectCode(req) {
72
+ if (!this.crossSiteSafe(req))
73
+ return 4403;
74
+ if (!this.currentLogin(req))
75
+ return 4401;
76
+ return null;
77
+ }
78
+ }
@@ -0,0 +1,41 @@
1
+ const MIN = 60_000;
2
+ const IP_WINDOW = 15 * MIN;
3
+ const IP_MAX_FAILURES = 5;
4
+ const IP_LOCK = 15 * MIN;
5
+ const GLOBAL_WINDOW = 60 * MIN;
6
+ const GLOBAL_MAX_FAILURES = 20;
7
+ const GLOBAL_LOCK = 60 * MIN;
8
+ export class Lockout {
9
+ now;
10
+ failures = new Map();
11
+ lockedUntil = new Map();
12
+ globalFailures = [];
13
+ globalLockedUntil = 0;
14
+ constructor(now = Date.now) {
15
+ this.now = now;
16
+ }
17
+ check(ip) {
18
+ const t = this.now();
19
+ const until = Math.max(this.lockedUntil.get(ip) ?? 0, this.globalLockedUntil);
20
+ return until > t ? { ok: false, retryAfterSec: Math.ceil((until - t) / 1000) } : { ok: true };
21
+ }
22
+ fail(ip) {
23
+ const t = this.now();
24
+ const recent = (this.failures.get(ip) ?? []).filter((x) => t - x < IP_WINDOW);
25
+ recent.push(t);
26
+ this.failures.set(ip, recent);
27
+ if (recent.length >= IP_MAX_FAILURES) {
28
+ this.lockedUntil.set(ip, t + IP_LOCK);
29
+ this.failures.delete(ip);
30
+ }
31
+ this.globalFailures = this.globalFailures.filter((x) => t - x < GLOBAL_WINDOW);
32
+ this.globalFailures.push(t);
33
+ if (this.globalFailures.length >= GLOBAL_MAX_FAILURES) {
34
+ this.globalLockedUntil = t + GLOBAL_LOCK;
35
+ this.globalFailures = [];
36
+ }
37
+ }
38
+ succeed(ip) {
39
+ this.failures.delete(ip);
40
+ }
41
+ }
@@ -0,0 +1,151 @@
1
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
2
+ const CHALLENGE_TTL = 5 * 60_000;
3
+ const MAX_CHALLENGES = 100;
4
+ export class ChallengeStore {
5
+ now;
6
+ items = new Map();
7
+ constructor(now = Date.now) {
8
+ this.now = now;
9
+ }
10
+ add(challenge, purpose) {
11
+ this.items.set(challenge, { purpose, expires: this.now() + CHALLENGE_TTL });
12
+ while (this.items.size > MAX_CHALLENGES)
13
+ this.items.delete(this.items.keys().next().value);
14
+ }
15
+ // Any presentation burns the challenge, even a mismatched one.
16
+ consume(challenge, purpose) {
17
+ const item = this.items.get(challenge);
18
+ this.items.delete(challenge);
19
+ return !!item && item.purpose === purpose && item.expires >= this.now();
20
+ }
21
+ }
22
+ const PUBLIC = { config: { public: true } };
23
+ function deviceName(ua) {
24
+ if (/iPhone/.test(ua))
25
+ return 'iPhone';
26
+ if (/iPad/.test(ua))
27
+ return 'iPad';
28
+ if (/Android/.test(ua))
29
+ return 'Android';
30
+ if (/Macintosh/.test(ua))
31
+ return 'Mac';
32
+ return 'Device';
33
+ }
34
+ export function registerPasskeyRoutes(app, ctx, deps) {
35
+ const { store, origin, rpId } = deps;
36
+ const now = deps.now ?? Date.now;
37
+ const challenges = new ChallengeStore(now);
38
+ const expect = (purpose) => (c) => challenges.consume(c, purpose);
39
+ app.post('/api/auth/passkey/register/options', async () => {
40
+ const user = store.get().user;
41
+ const options = await generateRegistrationOptions({
42
+ rpName: 'WorkOffDesk',
43
+ rpID: rpId,
44
+ userName: user.username,
45
+ userID: Uint8Array.from(Buffer.from(user.username)),
46
+ attestationType: 'none',
47
+ excludeCredentials: store.get().passkeys.map((p) => ({
48
+ id: p.id,
49
+ transports: p.transports,
50
+ })),
51
+ authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
52
+ });
53
+ challenges.add(options.challenge, 'register');
54
+ return options;
55
+ });
56
+ app.post('/api/auth/passkey/register/verify', async (req, reply) => {
57
+ const response = req.body?.response;
58
+ if (!response)
59
+ return reply.code(400).send({ error: 'registration failed' });
60
+ let result;
61
+ try {
62
+ result = await verifyRegistrationResponse({
63
+ response,
64
+ expectedChallenge: expect('register'),
65
+ expectedOrigin: origin,
66
+ expectedRPID: rpId,
67
+ requireUserVerification: true,
68
+ });
69
+ }
70
+ catch {
71
+ return reply.code(400).send({ error: 'registration failed' });
72
+ }
73
+ if (!result.verified || !result.registrationInfo)
74
+ return reply.code(400).send({ error: 'registration failed' });
75
+ const { credential } = result.registrationInfo;
76
+ const name = typeof req.body?.name === 'string' && req.body.name.trim()
77
+ ? req.body.name.trim().slice(0, 40)
78
+ : deviceName(String(req.headers['user-agent'] ?? ''));
79
+ const passkey = {
80
+ id: credential.id,
81
+ publicKey: Buffer.from(credential.publicKey).toString('base64url'),
82
+ counter: credential.counter,
83
+ transports: credential.transports,
84
+ name,
85
+ createdAt: now(),
86
+ };
87
+ store.update((s) => {
88
+ s.passkeys = [...s.passkeys.filter((p) => p.id !== passkey.id), passkey];
89
+ });
90
+ return reply.code(201).send({ id: passkey.id, name: passkey.name });
91
+ });
92
+ app.post('/api/auth/passkey/login/options', PUBLIC, async (req, reply) => {
93
+ if (!ctx.guard.originOk(req))
94
+ return reply.code(403).send({ error: 'bad origin' });
95
+ const options = await generateAuthenticationOptions({ rpID: rpId, userVerification: 'required' });
96
+ challenges.add(options.challenge, 'login');
97
+ return options;
98
+ });
99
+ app.post('/api/auth/passkey/login/verify', PUBLIC, async (req, reply) => {
100
+ if (!ctx.guard.originOk(req))
101
+ return reply.code(403).send({ error: 'bad origin' });
102
+ const response = req.body?.response;
103
+ const passkey = store.get().passkeys.find((p) => p.id === response?.id);
104
+ const fail = () => {
105
+ ctx.audit(req, 'passkey', false);
106
+ return reply.code(401).send({ error: 'passkey not accepted' });
107
+ };
108
+ if (!passkey || !response)
109
+ return fail();
110
+ let result;
111
+ try {
112
+ result = await verifyAuthenticationResponse({
113
+ response,
114
+ expectedChallenge: expect('login'),
115
+ expectedOrigin: origin,
116
+ expectedRPID: rpId,
117
+ requireUserVerification: true,
118
+ credential: {
119
+ id: passkey.id,
120
+ publicKey: Uint8Array.from(Buffer.from(passkey.publicKey, 'base64url')),
121
+ counter: passkey.counter,
122
+ transports: passkey.transports,
123
+ },
124
+ });
125
+ }
126
+ catch {
127
+ return fail();
128
+ }
129
+ if (!result.verified)
130
+ return fail();
131
+ store.update((s) => {
132
+ const p = s.passkeys.find((x) => x.id === passkey.id);
133
+ if (p) {
134
+ p.counter = result.authenticationInfo.newCounter;
135
+ p.lastUsedAt = now();
136
+ }
137
+ });
138
+ ctx.audit(req, 'passkey', true);
139
+ ctx.startLogin(req, reply, 'passkey');
140
+ return reply.code(204).send();
141
+ });
142
+ app.get('/api/auth/passkeys', async () => store.get().passkeys.map((p) => ({ id: p.id, name: p.name, createdAt: p.createdAt, lastUsedAt: p.lastUsedAt ?? null })));
143
+ app.delete('/api/auth/passkeys/:id', async (req, reply) => {
144
+ if (!store.get().passkeys.some((p) => p.id === req.params.id))
145
+ return reply.code(404).send({ error: 'not found' });
146
+ store.update((s) => {
147
+ s.passkeys = s.passkeys.filter((p) => p.id !== req.params.id);
148
+ });
149
+ return reply.code(204).send();
150
+ });
151
+ }
@@ -0,0 +1,23 @@
1
+ import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
2
+ const N = 2 ** 15;
3
+ const R = 8;
4
+ const P = 1;
5
+ const KEYLEN = 64;
6
+ // In characters, not bytes.
7
+ export const MIN_PASSWORD = 12;
8
+ function derive(pw, salt, opts) {
9
+ return new Promise((resolve, reject) => scrypt(pw, salt, KEYLEN, { ...opts, maxmem: 256 * N * R }, (err, key) => err ? reject(err) : resolve(key)));
10
+ }
11
+ export async function hashPassword(pw) {
12
+ const salt = randomBytes(16);
13
+ const key = await derive(pw, salt, { N, r: R, p: P });
14
+ return ['scrypt', N, R, P, salt.toString('base64'), key.toString('base64')].join('$');
15
+ }
16
+ export async function verifyPassword(pw, stored) {
17
+ const [alg, n, r, p, salt, hash] = stored.split('$');
18
+ if (alg !== 'scrypt' || !salt || !hash)
19
+ return false;
20
+ const expected = Buffer.from(hash, 'base64');
21
+ const key = await derive(pw, Buffer.from(salt, 'base64'), { N: +n, r: +r, p: +p });
22
+ return key.length === expected.length && timingSafeEqual(key, expected);
23
+ }
@@ -0,0 +1,46 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ const TTL = 30 * 24 * 60 * 60_000;
3
+ const TOUCH_INTERVAL = 60_000;
4
+ export class LoginSessions {
5
+ store;
6
+ now;
7
+ constructor(store, now = Date.now) {
8
+ this.store = store;
9
+ this.now = now;
10
+ }
11
+ create(ua, method) {
12
+ const id = randomBytes(32).toString('base64url');
13
+ const t = this.now();
14
+ this.store.update((s) => {
15
+ s.logins.push({ id, createdAt: t, lastSeen: t, ua, method });
16
+ });
17
+ return id;
18
+ }
19
+ validate(id) {
20
+ const t = this.now();
21
+ const login = this.store.get().logins.find((l) => l.id === id);
22
+ if (!login)
23
+ return null;
24
+ if (t - login.lastSeen > TTL) {
25
+ this.revoke(id);
26
+ return null;
27
+ }
28
+ if (t - login.lastSeen >= TOUCH_INTERVAL) {
29
+ this.store.update((s) => {
30
+ const l = s.logins.find((x) => x.id === id);
31
+ if (l)
32
+ l.lastSeen = t;
33
+ });
34
+ }
35
+ return this.store.get().logins.find((l) => l.id === id) ?? null;
36
+ }
37
+ revoke(id) {
38
+ this.store.update((s) => {
39
+ s.logins = s.logins.filter((l) => l.id !== id);
40
+ });
41
+ }
42
+ list() {
43
+ const t = this.now();
44
+ return this.store.get().logins.filter((l) => t - l.lastSeen <= TTL);
45
+ }
46
+ }
@@ -0,0 +1,97 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { loadAppUpdate, manifestOf, updateId } from '../../ios-update/appUpdate.js';
3
+ import { bundlePatcher, keepBundle } from '../../ios-update/bundlePatch.js';
4
+ import { PUBLIC } from './context.js';
5
+ // The iPhone app's JS over the expo-updates protocol (v1, JSON form): the update this build carries, for
6
+ // the native runtime it was built for, and nothing else. Public: the app keeps the headers it fetched an
7
+ // update with next to the update, outside the Keychain, so a token has no place here; and the bundle is
8
+ // the same one the npm package and the binary hold.
9
+ const HOST = /^(?:[a-z0-9.-]+|\[[0-9a-f:.]+\])(?::\d{1,5})?$/i;
10
+ // The server's own rule for its address: https, unless it is this machine (config.ts).
11
+ const LOCAL_HOST = /^(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
12
+ const FILES_PATH = '/api/app-update/assets';
13
+ const PROTOCOL = { 'expo-protocol-version': '1', 'expo-sfv-version': '0' };
14
+ // When this release became current, which orders it after whatever the phones ran before, a rollback included.
15
+ function activatedAt(file) {
16
+ if (!file)
17
+ return null;
18
+ try {
19
+ // An invalid date throws here too.
20
+ return new Date(readFileSync(file, 'utf8').trim()).toISOString();
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ export const register = (app, ctx) => {
27
+ const where = ctx.deps.appUpdate;
28
+ let served = null;
29
+ try {
30
+ served = where ? loadAppUpdate(where.dir) : null;
31
+ if (!served)
32
+ app.log.info('no app update in this build');
33
+ }
34
+ catch (err) {
35
+ app.log.error({ err }, 'app update in this build is unusable');
36
+ }
37
+ const createdAt = served ? (activatedAt(where?.activatedFile) ?? served.build.builtAt) : '';
38
+ const currentId = served ? updateId(served.build.runtimeVersion, served.build.launchAsset.hash, createdAt) : '';
39
+ const reportedFailed = new Set();
40
+ const bundle = served?.files.get(served.build.launchAsset.file);
41
+ let patchFrom = null;
42
+ if (bundle && where?.bundlesDir) {
43
+ try {
44
+ keepBundle(where.bundlesDir, currentId, bundle.bytes);
45
+ patchFrom = bundlePatcher(where.bundlesDir, bundle.bytes);
46
+ }
47
+ catch (err) {
48
+ app.log.error({ err }, 'cannot keep this app update for patches');
49
+ }
50
+ }
51
+ app.get('/api/app-update/manifest', PUBLIC, async (req, reply) => {
52
+ const header = (name) => {
53
+ const v = req.headers[name];
54
+ return typeof v === 'string' ? v : '';
55
+ };
56
+ if (header('expo-protocol-version') !== '1')
57
+ return reply.code(400).send({ error: 'expo-protocol-version 1 only' });
58
+ const runtime = header('expo-runtime-version');
59
+ if (!runtime)
60
+ return reply.code(400).send({ error: 'expo-runtime-version is required' });
61
+ const failed = [...header('expo-recent-failed-update-ids').matchAll(/"([^"]*)"/g)].map((m) => m[1].toLowerCase());
62
+ if (currentId && failed.includes(currentId) && !reportedFailed.has(currentId)) {
63
+ reportedFailed.add(currentId);
64
+ req.log.warn({ updateId: currentId }, 'app update failed to launch on a phone');
65
+ }
66
+ // Requests that went well are not logged; this is how one sees whether the phones took an update.
67
+ const checked = (offered) => req.log.info({ runtime, platform: header('expo-platform'), running: header('expo-current-update-id'), offered }, 'app update check');
68
+ if (!served || header('expo-platform') !== 'ios' || runtime !== served.build.runtimeVersion) {
69
+ checked(null);
70
+ return reply.code(204).headers(PROTOCOL).send();
71
+ }
72
+ const host = header('host');
73
+ if (!HOST.test(host))
74
+ return reply.code(400).send({ error: 'bad host' });
75
+ const filesBase = `${LOCAL_HOST.test(host) ? 'http' : 'https'}://${host}${FILES_PATH}`;
76
+ checked(currentId);
77
+ return reply.headers(PROTOCOL).send(manifestOf(served.build, createdAt, filesBase));
78
+ });
79
+ app.get(`${FILES_PATH}/:file`, PUBLIC, async (req, reply) => {
80
+ const file = served?.files.get(req.params.file);
81
+ if (!file)
82
+ return reply.code(404).send({ error: 'not found' });
83
+ const base = req.headers['expo-current-update-id'];
84
+ if (patchFrom && file === bundle && typeof base === 'string' && base !== currentId && /\bbsdiff\b/i.test(String(req.headers['a-im'] ?? ''))) {
85
+ const patch = await patchFrom(base).catch((err) => {
86
+ req.log.error({ err, base }, 'app update patch failed');
87
+ return null;
88
+ });
89
+ if (patch) {
90
+ req.log.info({ base, bytes: patch.length }, 'app update sent as a patch');
91
+ // The same URL answers with the whole bundle to anyone else, so no cache may keep this.
92
+ return reply.code(226).type('application/octet-stream').headers({ im: 'bsdiff', 'expo-base-update-id': base.toLowerCase(), 'cache-control': 'no-store', vary: 'A-IM, Expo-Current-Update-ID' }).send(patch);
93
+ }
94
+ }
95
+ return reply.type(file.contentType).header('cache-control', 'public, max-age=31536000, immutable').send(file.bytes);
96
+ });
97
+ };
@@ -0,0 +1,79 @@
1
+ import { clientIp, clearedCookie, loginHandle } from '../auth/guard.js';
2
+ import { Lockout } from '../auth/lockout.js';
3
+ import { verifyPassword } from '../auth/password.js';
4
+ import { PUBLIC } from './context.js';
5
+ // A fixed hash keeps the wrong-username path as slow as the wrong-password path.
6
+ const DUMMY_HASH = 'scrypt$32768$8$1$AAAAAAAAAAAAAAAAAAAAAA==$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==';
7
+ // Signing in with the password, signing out, and the devices signed in.
8
+ export const register = (app, ctx) => {
9
+ const { deps, guard, logins, audit, createLogin, startLogin, secure, now, dropRevokedSockets } = ctx;
10
+ const { store } = deps;
11
+ const lockout = new Lockout(now);
12
+ // One check at a time, so lockout counts every attempt before the next one starts.
13
+ let passwordTurn = Promise.resolve();
14
+ const oneAtATime = (fn) => {
15
+ const run = passwordTurn.then(fn);
16
+ passwordTurn = run.then(() => undefined, () => undefined);
17
+ return run;
18
+ };
19
+ app.post('/api/auth/password', PUBLIC, async (req, reply) => {
20
+ if (!guard.originOk(req))
21
+ return reply.code(403).send({ error: 'bad origin' });
22
+ return oneAtATime(() => passwordLogin(req, reply, () => {
23
+ startLogin(req, reply, 'password');
24
+ return reply.code(204).send();
25
+ }));
26
+ });
27
+ // The app's sign-in: the login comes back as a token for it to send in a header. No cookie is set, so
28
+ // there is nothing another site could ride on, and no Origin to check.
29
+ app.post('/api/auth/token', PUBLIC, async (req, reply) => oneAtATime(() => passwordLogin(req, reply, () => reply.send({ token: createLogin(req, 'password') }))));
30
+ const passwordLogin = async (req, reply, signIn) => {
31
+ const ip = clientIp(req);
32
+ const lock = lockout.check(ip);
33
+ if (!lock.ok) {
34
+ audit(req, 'password', false);
35
+ return reply.code(429).header('retry-after', String(lock.retryAfterSec)).send({ error: 'locked' });
36
+ }
37
+ const { username, password } = req.body ?? {};
38
+ const user = store.get().user;
39
+ const nameOk = !!user && typeof username === 'string' && username === user.username;
40
+ const pwOk = await verifyPassword(typeof password === 'string' ? password : '', nameOk ? user.hash : DUMMY_HASH);
41
+ const ok = nameOk && pwOk;
42
+ audit(req, 'password', ok);
43
+ if (!ok) {
44
+ lockout.fail(ip);
45
+ return reply.code(401).send({ error: 'wrong username or password' });
46
+ }
47
+ lockout.succeed(ip);
48
+ return signIn();
49
+ };
50
+ app.post('/api/auth/logout', PUBLIC, async (req, reply) => {
51
+ if (!guard.crossSiteSafe(req))
52
+ return reply.code(403).send({ error: 'bad origin' });
53
+ const login = guard.currentLogin(req);
54
+ if (login)
55
+ logins.revoke(login.loginId);
56
+ dropRevokedSockets();
57
+ return reply.code(204).header('set-cookie', clearedCookie(secure)).send();
58
+ });
59
+ app.get('/api/me', async () => ({ username: store.get().user?.username }));
60
+ app.get('/api/auth/logins', async (req) => {
61
+ const current = guard.currentLogin(req).loginId;
62
+ return logins.list().map((l) => ({
63
+ handle: loginHandle(l.id),
64
+ createdAt: l.createdAt,
65
+ lastSeen: l.lastSeen,
66
+ ua: l.ua,
67
+ method: l.method,
68
+ current: l.id === current,
69
+ }));
70
+ });
71
+ app.delete('/api/auth/logins/:handle', async (req, reply) => {
72
+ const target = logins.list().find((l) => loginHandle(l.id) === req.params.handle);
73
+ if (!target)
74
+ return reply.code(404).send({ error: 'not found' });
75
+ logins.revoke(target.id);
76
+ dropRevokedSockets();
77
+ return reply.code(204).send();
78
+ });
79
+ };