sideby 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +111 -2
- package/README.zh-CN.md +112 -0
- package/dist/build.json +1 -0
- package/dist/src/cli/args.d.ts +13 -0
- package/dist/src/cli/args.js +38 -0
- package/dist/src/cli/commands.d.ts +50 -0
- package/dist/src/cli/commands.js +566 -0
- package/dist/src/cli/format.d.ts +16 -0
- package/dist/src/cli/format.js +80 -0
- package/dist/src/cli/json-schemas.d.ts +2 -0
- package/dist/src/cli/json-schemas.js +259 -0
- package/dist/src/cli/main.d.ts +2 -0
- package/dist/src/cli/main.js +276 -0
- package/dist/src/cli/schemas.d.ts +1 -0
- package/dist/src/cli/schemas.js +16 -0
- package/dist/src/core/account-name.d.ts +2 -0
- package/dist/src/core/account-name.js +2 -0
- package/dist/src/core/accounts.d.ts +20 -0
- package/dist/src/core/accounts.js +131 -0
- package/dist/src/core/config.d.ts +61 -0
- package/dist/src/core/config.js +184 -0
- package/dist/src/core/create.d.ts +30 -0
- package/dist/src/core/create.js +126 -0
- package/dist/src/core/doctor.d.ts +41 -0
- package/dist/src/core/doctor.js +171 -0
- package/dist/src/core/errors.d.ts +12 -0
- package/dist/src/core/errors.js +14 -0
- package/dist/src/core/file-memo.d.ts +24 -0
- package/dist/src/core/file-memo.js +41 -0
- package/dist/src/core/fs-safe.d.ts +38 -0
- package/dist/src/core/fs-safe.js +205 -0
- package/dist/src/core/handoff.d.ts +41 -0
- package/dist/src/core/handoff.js +54 -0
- package/dist/src/core/identity.d.ts +23 -0
- package/dist/src/core/identity.js +79 -0
- package/dist/src/core/last-doctor.d.ts +36 -0
- package/dist/src/core/last-doctor.js +69 -0
- package/dist/src/core/launch.d.ts +37 -0
- package/dist/src/core/launch.js +108 -0
- package/dist/src/core/login-path.d.ts +23 -0
- package/dist/src/core/login-path.js +96 -0
- package/dist/src/core/paths.d.ts +13 -0
- package/dist/src/core/paths.js +32 -0
- package/dist/src/core/quota-levels.d.ts +19 -0
- package/dist/src/core/quota-levels.js +34 -0
- package/dist/src/core/resume.d.ts +9 -0
- package/dist/src/core/resume.js +15 -0
- package/dist/src/core/secret-file.d.ts +30 -0
- package/dist/src/core/secret-file.js +116 -0
- package/dist/src/core/share-meaning.d.ts +6 -0
- package/dist/src/core/share-meaning.js +32 -0
- package/dist/src/core/share-mode-names.d.ts +1 -0
- package/dist/src/core/share-mode-names.js +10 -0
- package/dist/src/core/share-modes.d.ts +24 -0
- package/dist/src/core/share-modes.js +405 -0
- package/dist/src/core/shell-init.d.ts +31 -0
- package/dist/src/core/shell-init.js +84 -0
- package/dist/src/core/skill-check.d.ts +11 -0
- package/dist/src/core/skill-check.js +57 -0
- package/dist/src/core/usage-days.d.ts +15 -0
- package/dist/src/core/usage-days.js +33 -0
- package/dist/src/core/version.d.ts +9 -0
- package/dist/src/core/version.js +46 -0
- package/dist/src/desktop/app.d.ts +60 -0
- package/dist/src/desktop/app.js +316 -0
- package/dist/src/desktop/icon.d.ts +16 -0
- package/dist/src/desktop/icon.js +174 -0
- package/dist/src/families/claude/index.d.ts +10 -0
- package/dist/src/families/claude/index.js +127 -0
- package/dist/src/families/claude/layout.d.ts +6 -0
- package/dist/src/families/claude/layout.js +5 -0
- package/dist/src/families/claude/quota-cache.d.ts +31 -0
- package/dist/src/families/claude/quota-cache.js +111 -0
- package/dist/src/families/claude/quota.d.ts +3 -0
- package/dist/src/families/claude/quota.js +53 -0
- package/dist/src/families/claude/usage.d.ts +22 -0
- package/dist/src/families/claude/usage.js +146 -0
- package/dist/src/families/codex/index.d.ts +5 -0
- package/dist/src/families/codex/index.js +70 -0
- package/dist/src/families/codex/rollout.d.ts +67 -0
- package/dist/src/families/codex/rollout.js +298 -0
- package/dist/src/families/grok/index.d.ts +8 -0
- package/dist/src/families/grok/index.js +82 -0
- package/dist/src/families/index.d.ts +3 -0
- package/dist/src/families/index.js +40 -0
- package/dist/src/families/logos.d.ts +21 -0
- package/dist/src/families/logos.js +26 -0
- package/dist/src/families/pi/index.d.ts +3 -0
- package/dist/src/families/pi/index.js +54 -0
- package/dist/src/families/shared/sessions.d.ts +9 -0
- package/dist/src/families/shared/sessions.js +70 -0
- package/dist/src/families/shared/toml.d.ts +5 -0
- package/dist/src/families/shared/toml.js +16 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +4 -0
- package/dist/src/panel/background.d.ts +62 -0
- package/dist/src/panel/background.js +274 -0
- package/dist/src/panel/handler.d.ts +91 -0
- package/dist/src/panel/handler.js +396 -0
- package/dist/src/panel/i18n.d.ts +287 -0
- package/dist/src/panel/i18n.js +570 -0
- package/dist/src/panel/page-logic.d.ts +154 -0
- package/dist/src/panel/page-logic.js +326 -0
- package/dist/src/panel/page-script.d.ts +2 -0
- package/dist/src/panel/page-script.js +1633 -0
- package/dist/src/panel/page-styles.d.ts +7 -0
- package/dist/src/panel/page-styles.js +514 -0
- package/dist/src/panel/page.d.ts +20 -0
- package/dist/src/panel/page.js +96 -0
- package/dist/src/panel/server.d.ts +32 -0
- package/dist/src/panel/server.js +133 -0
- package/dist/src/panel/theme.d.ts +116 -0
- package/dist/src/panel/theme.js +132 -0
- package/dist/src/plugins/account-script.d.ts +4 -0
- package/dist/src/plugins/account-script.js +66 -0
- package/dist/src/plugins/bus.d.ts +33 -0
- package/dist/src/plugins/bus.js +84 -0
- package/dist/src/plugins/loader.d.ts +58 -0
- package/dist/src/plugins/loader.js +250 -0
- package/dist/src/quota/claude-statusline.d.ts +28 -0
- package/dist/src/quota/claude-statusline.js +301 -0
- package/dist/src/quota/statusline-tap.d.ts +11 -0
- package/dist/src/quota/statusline-tap.js +104 -0
- package/dist/src/runtime.d.ts +195 -0
- package/dist/src/runtime.js +424 -0
- package/dist/src/types.d.ts +285 -0
- package/dist/src/types.js +3 -0
- package/dist/testing/demo.d.ts +8 -0
- package/dist/testing/demo.js +50 -0
- package/dist/testing/index.d.ts +36 -0
- package/dist/testing/index.js +118 -0
- package/package.json +66 -4
- package/schemas/alias.json +102 -0
- package/schemas/app-install.json +74 -0
- package/schemas/app-uninstall.json +54 -0
- package/schemas/config.json +170 -0
- package/schemas/doctor.json +229 -0
- package/schemas/error.json +25 -0
- package/schemas/families.json +205 -0
- package/schemas/list.json +177 -0
- package/schemas/new.json +199 -0
- package/schemas/next.json +127 -0
- package/schemas/plugins.json +74 -0
- package/schemas/quota-setup.json +60 -0
- package/schemas/quota-teardown.json +30 -0
- package/schemas/quota.json +203 -0
- package/skills/sideby/SKILL.md +61 -0
- package/skills/sideby/references/accounts.md +31 -0
- package/skills/sideby/references/diagnose.md +47 -0
- package/skills/sideby/references/handoff.md +41 -0
- package/skills/sideby/references/quota.md +14 -0
- package/skills/sideby/references/setup.md +31 -0
- package/skills/sideby/references/sharing.md +17 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { AccountStatus, DoctorHistory, FamilyInfo, Runtime } from '../runtime.ts';
|
|
3
|
+
import type { Account, FamilyLogo, QuotaResult, QuotaSetupPlan, UsageResult } from '../types.ts';
|
|
4
|
+
import { type PanelTheme } from './theme.ts';
|
|
5
|
+
export declare const PANEL_VERSION: string;
|
|
6
|
+
export declare const MAX_BODY_BYTES: number;
|
|
7
|
+
/** Write requests allowed to wait behind the running one; more get 409. */
|
|
8
|
+
export declare const MAX_QUEUED_WRITES = 4;
|
|
9
|
+
export interface PanelHandlerOptions {
|
|
10
|
+
/** A Runtime, or a factory called per request so every response reflects the disk. */
|
|
11
|
+
runtime: Runtime | (() => Promise<Runtime>);
|
|
12
|
+
/** Mount point such as `/accounts`; empty or `/` for the root. */
|
|
13
|
+
basePath?: string;
|
|
14
|
+
/** Accepted `Host` header values, for example `127.0.0.1:17420`. */
|
|
15
|
+
allowedHosts: string[];
|
|
16
|
+
/** Rejects changes (fix, create, quota setup apply and teardown) with 403. */
|
|
17
|
+
readOnly?: boolean;
|
|
18
|
+
version?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Design tokens that make the page match the embedding page. Checked when the handler is created: an unknown
|
|
21
|
+
* token or a value that could leave its CSS declaration throws a TypeError.
|
|
22
|
+
*/
|
|
23
|
+
theme?: PanelTheme;
|
|
24
|
+
/**
|
|
25
|
+
* The Panel's language. `en` or `zh` wins over what the viewer saved and hides the Panel's own switch; `auto`
|
|
26
|
+
* (the default) uses the viewer's saved choice, then the browser language. Checked when the handler is created:
|
|
27
|
+
* any other value throws a TypeError. A Portal that changes language creates the handler again.
|
|
28
|
+
*/
|
|
29
|
+
lang?: 'auto' | 'en' | 'zh';
|
|
30
|
+
}
|
|
31
|
+
export type PanelHandler = ((req: IncomingMessage, res: ServerResponse) => Promise<boolean>) & {
|
|
32
|
+
/** Per-instance secret the page sends as `x-sideby-token`. */
|
|
33
|
+
readonly token: string;
|
|
34
|
+
};
|
|
35
|
+
export type PanelAccount = (AccountStatus | (Account & Partial<AccountStatus>)) & {
|
|
36
|
+
quota?: QuotaResult;
|
|
37
|
+
usage?: UsageResult;
|
|
38
|
+
error?: string;
|
|
39
|
+
/** Latest Doctor result for this Account; absent when it was never checked. */
|
|
40
|
+
health?: {
|
|
41
|
+
at: string;
|
|
42
|
+
fail: number;
|
|
43
|
+
warn: number;
|
|
44
|
+
};
|
|
45
|
+
/** Config `aliases` that point at this Account, sorted; absent when none do. */
|
|
46
|
+
aliases?: string[];
|
|
47
|
+
/** Host arguments of those aliases that add any (alias → arguments); absent when none do. */
|
|
48
|
+
aliasArgs?: Record<string, string[]>;
|
|
49
|
+
};
|
|
50
|
+
export type { DoctorHistory };
|
|
51
|
+
/** Counts of failing and warning Findings per checked Account, from the Doctor history. */
|
|
52
|
+
export declare function healthByAccount(history: DoctorHistory | undefined): Map<string, {
|
|
53
|
+
at: string;
|
|
54
|
+
fail: number;
|
|
55
|
+
warn: number;
|
|
56
|
+
}>;
|
|
57
|
+
/** A Family as the page sees it: FamilyInfo plus the mark from its FamilyDef. */
|
|
58
|
+
export type PanelFamily = FamilyInfo & {
|
|
59
|
+
logo?: FamilyLogo;
|
|
60
|
+
};
|
|
61
|
+
export interface PanelState {
|
|
62
|
+
schemaVersion: 1;
|
|
63
|
+
version: string;
|
|
64
|
+
/** buildId() of the process serving the Panel: a page from another build reloads itself. */
|
|
65
|
+
build: string;
|
|
66
|
+
readOnly: boolean;
|
|
67
|
+
generatedAt: string;
|
|
68
|
+
families: PanelFamily[];
|
|
69
|
+
accounts: PanelAccount[];
|
|
70
|
+
pluginErrors: {
|
|
71
|
+
where: string;
|
|
72
|
+
message: string;
|
|
73
|
+
}[];
|
|
74
|
+
quotaSetups: {
|
|
75
|
+
family: string;
|
|
76
|
+
summary: string;
|
|
77
|
+
plan: QuotaSetupPlan;
|
|
78
|
+
}[];
|
|
79
|
+
/** Latest Doctor result per Account and per Family. */
|
|
80
|
+
history?: DoctorHistory;
|
|
81
|
+
/** The config `aliases` as alias → Account ref (arguments left out), so the page can tell which names are taken. */
|
|
82
|
+
configAliases?: Record<string, string>;
|
|
83
|
+
/**
|
|
84
|
+
* Family → the Account `sideby next <family>` would pick (spec §3.13). Only Families with a Quota source,
|
|
85
|
+
* at least two subscription Accounts, some Quota data and a pick; API Accounts are left out as in the CLI default.
|
|
86
|
+
*/
|
|
87
|
+
handoff?: Record<string, string>;
|
|
88
|
+
}
|
|
89
|
+
/** The `handoff` map of the Panel state, from the statuses and Quota already read for the page. */
|
|
90
|
+
export declare function handoffByFamily(rt: Runtime, accounts: readonly PanelAccount[], now: Date): Record<string, string>;
|
|
91
|
+
export declare function createPanelHandler(opts: PanelHandlerOptions): PanelHandler;
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// HTTP handler for the local Panel. Mountable under any basePath; standalone and embedded use the same rules.
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { aliasesByAccount } from '../core/accounts.js';
|
|
4
|
+
import { aliasArgs, aliasRefs } from '../core/config.js';
|
|
5
|
+
import { UserError } from '../core/errors.js';
|
|
6
|
+
import { planHandoff } from '../core/handoff.js';
|
|
7
|
+
import { buildId, packageVersion } from '../core/version.js';
|
|
8
|
+
import { renderPage } from './page.js';
|
|
9
|
+
import { resolveTheme } from './theme.js';
|
|
10
|
+
export const PANEL_VERSION = packageVersion();
|
|
11
|
+
export const MAX_BODY_BYTES = 64 * 1024;
|
|
12
|
+
/** Write requests allowed to wait behind the running one; more get 409. */
|
|
13
|
+
export const MAX_QUEUED_WRITES = 4;
|
|
14
|
+
const PANEL_LANGS = ['auto', 'en', 'zh'];
|
|
15
|
+
function resolveLang(lang) {
|
|
16
|
+
if (lang === undefined)
|
|
17
|
+
return 'auto';
|
|
18
|
+
const found = PANEL_LANGS.find((l) => l === lang);
|
|
19
|
+
if (!found)
|
|
20
|
+
throw new TypeError('panel lang must be "auto", "en" or "zh"');
|
|
21
|
+
return found;
|
|
22
|
+
}
|
|
23
|
+
/** Counts of failing and warning Findings per checked Account, from the Doctor history. */
|
|
24
|
+
export function healthByAccount(history) {
|
|
25
|
+
const out = new Map();
|
|
26
|
+
for (const [ref, entry] of Object.entries(history?.accounts ?? {}))
|
|
27
|
+
out.set(ref, {
|
|
28
|
+
at: entry.at,
|
|
29
|
+
fail: entry.findings.filter((f) => f.level === 'fail').length,
|
|
30
|
+
warn: entry.findings.filter((f) => f.level === 'warn').length,
|
|
31
|
+
});
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/** The `handoff` map of the Panel state, from the statuses and Quota already read for the page. */
|
|
35
|
+
export function handoffByFamily(rt, accounts, now) {
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [id, f] of rt.families) {
|
|
38
|
+
const list = accounts.filter((a) => a.family === id);
|
|
39
|
+
if (!f.readQuota || list.filter((a) => a.kind === 'subscription').length < 2)
|
|
40
|
+
continue;
|
|
41
|
+
const plan = planHandoff(id, list.map((a) => ({
|
|
42
|
+
ref: a.ref,
|
|
43
|
+
name: a.name,
|
|
44
|
+
isMain: a.isMain,
|
|
45
|
+
kind: a.kind,
|
|
46
|
+
login: a.login ?? 'unknown',
|
|
47
|
+
quota: a.quota ?? { status: 'unavailable', reason: 'unrecognized' },
|
|
48
|
+
})), now);
|
|
49
|
+
if (plan.pick && plan.hasQuota)
|
|
50
|
+
out[id] = plan.pick;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
class HttpError extends Error {
|
|
55
|
+
status;
|
|
56
|
+
/** Machine-readable reason the page acts on, such as `bad-token`. */
|
|
57
|
+
code;
|
|
58
|
+
constructor(status, message, code) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.status = status;
|
|
61
|
+
if (code)
|
|
62
|
+
this.code = code;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function normalizeBase(p) {
|
|
66
|
+
if (!p || p === '/')
|
|
67
|
+
return '';
|
|
68
|
+
const withSlash = p.startsWith('/') ? p : `/${p}`;
|
|
69
|
+
return withSlash.replace(/\/+$/, '');
|
|
70
|
+
}
|
|
71
|
+
function sameSecret(given, expected) {
|
|
72
|
+
const a = Buffer.from(given);
|
|
73
|
+
const b = Buffer.from(expected);
|
|
74
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
75
|
+
}
|
|
76
|
+
function send(res, status, body, extra = {}) {
|
|
77
|
+
const data = JSON.stringify(body);
|
|
78
|
+
res.writeHead(status, {
|
|
79
|
+
'content-type': 'application/json; charset=utf-8',
|
|
80
|
+
'cache-control': 'no-store',
|
|
81
|
+
'x-content-type-options': 'nosniff',
|
|
82
|
+
...extra,
|
|
83
|
+
});
|
|
84
|
+
res.end(data);
|
|
85
|
+
}
|
|
86
|
+
function readBody(req) {
|
|
87
|
+
const declared = Number(req.headers['content-length']);
|
|
88
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES)
|
|
89
|
+
return Promise.reject(new HttpError(413, `request body over ${MAX_BODY_BYTES} bytes`));
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const chunks = [];
|
|
92
|
+
let size = 0;
|
|
93
|
+
let failed = false;
|
|
94
|
+
req.on('data', (c) => {
|
|
95
|
+
if (failed)
|
|
96
|
+
return;
|
|
97
|
+
size += c.length;
|
|
98
|
+
if (size > MAX_BODY_BYTES) {
|
|
99
|
+
failed = true;
|
|
100
|
+
reject(new HttpError(413, `request body over ${MAX_BODY_BYTES} bytes`));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
chunks.push(c);
|
|
104
|
+
});
|
|
105
|
+
req.on('error', (err) => {
|
|
106
|
+
if (!failed)
|
|
107
|
+
reject(err);
|
|
108
|
+
});
|
|
109
|
+
req.on('end', () => {
|
|
110
|
+
if (failed)
|
|
111
|
+
return;
|
|
112
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
113
|
+
if (!raw)
|
|
114
|
+
return resolve({});
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(raw);
|
|
117
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
118
|
+
return reject(new HttpError(400, 'request body must be a JSON object'));
|
|
119
|
+
resolve(parsed);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
reject(new HttpError(400, 'request body is not valid JSON'));
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function optString(body, key) {
|
|
128
|
+
const v = body[key];
|
|
129
|
+
if (v === undefined || v === null || v === '')
|
|
130
|
+
return undefined;
|
|
131
|
+
if (typeof v !== 'string')
|
|
132
|
+
throw new HttpError(400, `"${key}" must be a string`);
|
|
133
|
+
return v;
|
|
134
|
+
}
|
|
135
|
+
function optBool(body, key) {
|
|
136
|
+
const v = body[key];
|
|
137
|
+
if (v === undefined || v === null)
|
|
138
|
+
return false;
|
|
139
|
+
if (typeof v !== 'boolean')
|
|
140
|
+
throw new HttpError(400, `"${key}" must be true or false`);
|
|
141
|
+
return v;
|
|
142
|
+
}
|
|
143
|
+
async function accountState(rt, account, families) {
|
|
144
|
+
const errors = [];
|
|
145
|
+
const guard = async (p) => {
|
|
146
|
+
try {
|
|
147
|
+
return await p;
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
errors.push(err.message);
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const [status, quota, usage] = await Promise.all([
|
|
155
|
+
guard(rt.status(account)),
|
|
156
|
+
guard(rt.quota(account)),
|
|
157
|
+
guard(rt.usage(account)),
|
|
158
|
+
]);
|
|
159
|
+
const fam = families.find((f) => f.id === account.family);
|
|
160
|
+
const base = status ?? {
|
|
161
|
+
...account,
|
|
162
|
+
familyTitle: fam?.title ?? account.family,
|
|
163
|
+
hostInstalled: fam?.installed ?? false,
|
|
164
|
+
login: 'unknown',
|
|
165
|
+
};
|
|
166
|
+
errors.push(...(status?.problems ?? []));
|
|
167
|
+
return {
|
|
168
|
+
...base,
|
|
169
|
+
...(quota ? { quota } : {}),
|
|
170
|
+
...(usage ? { usage } : {}),
|
|
171
|
+
...(errors.length ? { error: errors.join('; ') } : {}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
async function buildState(rt, opts) {
|
|
175
|
+
const ctx = rt.readContext();
|
|
176
|
+
const [families, accounts, history, quotaSetups] = await Promise.all([
|
|
177
|
+
rt.familyInfo(),
|
|
178
|
+
rt.accounts(),
|
|
179
|
+
rt.doctorHistory(),
|
|
180
|
+
rt.quotaSetups(),
|
|
181
|
+
]);
|
|
182
|
+
const health = healthByAccount(history);
|
|
183
|
+
const aliases = aliasesByAccount(aliasRefs(rt.config.aliases), accounts);
|
|
184
|
+
const list = await Promise.all(accounts.map(async (a) => {
|
|
185
|
+
const st = await accountState(rt, a, families);
|
|
186
|
+
const h = health.get(a.ref);
|
|
187
|
+
const names = aliases.get(a.ref);
|
|
188
|
+
const withArgs = (names ?? [])
|
|
189
|
+
.map((n) => [n, aliasArgs(rt.config.aliases?.[n])])
|
|
190
|
+
.filter(([, args]) => args.length);
|
|
191
|
+
return {
|
|
192
|
+
...st,
|
|
193
|
+
...(h ? { health: h } : {}),
|
|
194
|
+
...(names ? { aliases: names } : {}),
|
|
195
|
+
...(withArgs.length ? { aliasArgs: Object.fromEntries(withArgs) } : {}),
|
|
196
|
+
};
|
|
197
|
+
}));
|
|
198
|
+
const handoff = handoffByFamily(rt, list, ctx.now);
|
|
199
|
+
return {
|
|
200
|
+
schemaVersion: 1,
|
|
201
|
+
version: opts.version,
|
|
202
|
+
build: opts.build,
|
|
203
|
+
readOnly: opts.readOnly,
|
|
204
|
+
generatedAt: ctx.now.toISOString(),
|
|
205
|
+
families: families.map((f) => {
|
|
206
|
+
const logo = rt.families.get(f.id)?.logo;
|
|
207
|
+
return logo ? { ...f, logo } : f;
|
|
208
|
+
}),
|
|
209
|
+
accounts: list,
|
|
210
|
+
pluginErrors: rt.pluginErrors.map((e) => ({ where: e.where, message: e.message })),
|
|
211
|
+
quotaSetups,
|
|
212
|
+
...(history ? { history } : {}),
|
|
213
|
+
...(rt.config.aliases ? { configAliases: aliasRefs(rt.config.aliases) } : {}),
|
|
214
|
+
...(Object.keys(handoff).length ? { handoff } : {}),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/** Runs write requests one at a time; a few may wait, the rest are told to retry. */
|
|
218
|
+
function writeQueue() {
|
|
219
|
+
let tail = Promise.resolve();
|
|
220
|
+
let inFlight = 0;
|
|
221
|
+
return (fn) => {
|
|
222
|
+
if (inFlight > MAX_QUEUED_WRITES)
|
|
223
|
+
return Promise.reject(new HttpError(409, 'another change is still running; try again in a moment'));
|
|
224
|
+
inFlight++;
|
|
225
|
+
const run = tail.then(fn);
|
|
226
|
+
tail = run.catch(() => undefined);
|
|
227
|
+
return run.finally(() => {
|
|
228
|
+
inFlight--;
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
export function createPanelHandler(opts) {
|
|
233
|
+
const basePath = normalizeBase(opts.basePath);
|
|
234
|
+
const allowed = new Set(opts.allowedHosts.map((h) => h.toLowerCase()));
|
|
235
|
+
const readOnly = Boolean(opts.readOnly);
|
|
236
|
+
const version = opts.version ?? PANEL_VERSION;
|
|
237
|
+
const build = buildId();
|
|
238
|
+
const theme = resolveTheme(opts.theme);
|
|
239
|
+
const lang = resolveLang(opts.lang);
|
|
240
|
+
const token = randomBytes(32).toString('base64url');
|
|
241
|
+
const serial = writeQueue();
|
|
242
|
+
const getRuntime = typeof opts.runtime === 'function' ? opts.runtime : async () => opts.runtime;
|
|
243
|
+
const routes = {
|
|
244
|
+
'/api/doctor': {
|
|
245
|
+
// Only the record of the check is written, and a read-only Panel skips that (persist: false).
|
|
246
|
+
writes: () => false,
|
|
247
|
+
async run(body) {
|
|
248
|
+
const target = optString(body, 'target');
|
|
249
|
+
const rt = await getRuntime();
|
|
250
|
+
return rt.doctorWithHistory({ persist: !readOnly, ...(target ? { target } : {}) });
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
'/api/fix': {
|
|
254
|
+
async run(body) {
|
|
255
|
+
const target = optString(body, 'target');
|
|
256
|
+
const force = optBool(body, 'force');
|
|
257
|
+
const rt = await getRuntime();
|
|
258
|
+
return rt.doctorWithHistory({ fix: true, force, ...(target ? { target } : {}) });
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
'/api/accounts': {
|
|
262
|
+
async run(body) {
|
|
263
|
+
const family = optString(body, 'family');
|
|
264
|
+
const name = optString(body, 'name');
|
|
265
|
+
if (!family || !name)
|
|
266
|
+
throw new HttpError(400, 'pick a family and enter a name');
|
|
267
|
+
const api = optBool(body, 'api');
|
|
268
|
+
const alias = optString(body, 'alias');
|
|
269
|
+
const rt = await getRuntime();
|
|
270
|
+
return rt.createAccount(family, name, { api, ...(alias ? { alias } : {}) });
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
'/api/quota/setup': {
|
|
274
|
+
writes: (body) => body.confirm === true,
|
|
275
|
+
async run(body) {
|
|
276
|
+
const family = optString(body, 'family');
|
|
277
|
+
const confirm = optBool(body, 'confirm');
|
|
278
|
+
if (!family)
|
|
279
|
+
throw new HttpError(400, '"family" is required');
|
|
280
|
+
const rt = await getRuntime();
|
|
281
|
+
const setup = rt.quotaSetup(family);
|
|
282
|
+
const plan = confirm ? await setup.apply() : await setup.plan();
|
|
283
|
+
return { family, summary: setup.summary, plan };
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
// Undo for the setup above; the Runtime turns a teardown that throws into `{ ok: false, message }`.
|
|
287
|
+
'/api/quota/teardown': {
|
|
288
|
+
async run(body) {
|
|
289
|
+
const family = optString(body, 'family');
|
|
290
|
+
if (!family)
|
|
291
|
+
throw new HttpError(400, '"family" is required');
|
|
292
|
+
const rt = await getRuntime();
|
|
293
|
+
return { family, result: await rt.quotaSetup(family).teardown() };
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
const handle = async (req, res) => {
|
|
298
|
+
let pathname;
|
|
299
|
+
try {
|
|
300
|
+
pathname = new URL(req.url ?? '/', 'http://x').pathname;
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
if (basePath && pathname !== basePath && !pathname.startsWith(`${basePath}/`))
|
|
306
|
+
return false;
|
|
307
|
+
const sub = pathname.slice(basePath.length) || '/';
|
|
308
|
+
try {
|
|
309
|
+
const host = (req.headers.host ?? '').toLowerCase();
|
|
310
|
+
if (!allowed.has(host))
|
|
311
|
+
throw new HttpError(403, 'host not allowed');
|
|
312
|
+
const method = req.method ?? 'GET';
|
|
313
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
314
|
+
if (sub === '/' || sub === '/index.html') {
|
|
315
|
+
const nonce = randomBytes(16).toString('base64');
|
|
316
|
+
res.writeHead(200, {
|
|
317
|
+
'content-type': 'text/html; charset=utf-8',
|
|
318
|
+
'cache-control': 'no-store',
|
|
319
|
+
'x-content-type-options': 'nosniff',
|
|
320
|
+
'referrer-policy': 'no-referrer',
|
|
321
|
+
'content-security-policy': [
|
|
322
|
+
"default-src 'none'",
|
|
323
|
+
`script-src 'nonce-${nonce}'`,
|
|
324
|
+
`style-src 'nonce-${nonce}'`,
|
|
325
|
+
"connect-src 'self'",
|
|
326
|
+
"img-src 'self' data:",
|
|
327
|
+
"base-uri 'none'",
|
|
328
|
+
"form-action 'none'",
|
|
329
|
+
"frame-ancestors 'self'",
|
|
330
|
+
].join('; '),
|
|
331
|
+
});
|
|
332
|
+
res.end(method === 'HEAD'
|
|
333
|
+
? undefined
|
|
334
|
+
: renderPage({ basePath, token, nonce, version, build, readOnly, theme, lang }));
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
if (sub === '/api/health') {
|
|
338
|
+
send(res, 200, { app: 'sideby', version, build });
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
// The page fetches a fresh token here after the server restarted (a new process, a new token).
|
|
342
|
+
// It reveals nothing a same-origin read of the page does not; a cross-site page cannot read it.
|
|
343
|
+
if (sub === '/api/session') {
|
|
344
|
+
if (req.headers['sec-fetch-site'] === 'cross-site')
|
|
345
|
+
throw new HttpError(403, 'origin not allowed');
|
|
346
|
+
send(res, 200, { token });
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
if (sub === '/api/state') {
|
|
350
|
+
send(res, 200, await buildState(await getRuntime(), { version, build, readOnly }));
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
if (Object.hasOwn(routes, sub))
|
|
354
|
+
throw new HttpError(405, 'use POST');
|
|
355
|
+
throw new HttpError(404, 'not found');
|
|
356
|
+
}
|
|
357
|
+
if (method !== 'POST')
|
|
358
|
+
throw new HttpError(405, 'method not allowed');
|
|
359
|
+
const route = Object.hasOwn(routes, sub) ? routes[sub] : undefined;
|
|
360
|
+
if (!route)
|
|
361
|
+
throw new HttpError(404, 'not found');
|
|
362
|
+
const origin = req.headers.origin;
|
|
363
|
+
if (!origin || origin.toLowerCase() !== `http://${host}`)
|
|
364
|
+
throw new HttpError(403, 'origin not allowed');
|
|
365
|
+
const given = req.headers['x-sideby-token'];
|
|
366
|
+
if (typeof given !== 'string' || !sameSecret(given, token))
|
|
367
|
+
throw new HttpError(403, 'missing or wrong x-sideby-token; reload the page', 'bad-token');
|
|
368
|
+
const body = (await readBody(req));
|
|
369
|
+
if (readOnly && (route.writes?.(body) ?? true))
|
|
370
|
+
throw new HttpError(403, 'this panel is read-only');
|
|
371
|
+
// Reads that record history (doctor) also run in this queue, so overlapping checks merge in order.
|
|
372
|
+
send(res, 200, await serial(() => route.run(body)));
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
if (res.headersSent) {
|
|
377
|
+
res.end();
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
if (err instanceof HttpError) {
|
|
381
|
+
const body = err.code ? { error: err.message, code: err.code } : { error: err.message };
|
|
382
|
+
send(res, err.status, body, err.status === 413 ? { connection: 'close' } : {});
|
|
383
|
+
if (err.status === 413)
|
|
384
|
+
req.resume();
|
|
385
|
+
}
|
|
386
|
+
else if (err instanceof UserError) {
|
|
387
|
+
send(res, 400, err.code ? { error: err.message, code: err.code } : { error: err.message });
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
send(res, 500, { error: err?.message || 'internal error' });
|
|
391
|
+
}
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
return Object.assign(handle, { token });
|
|
396
|
+
}
|