@prismshadow/penguin-server 0.2.15 → 0.2.17
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 +1 -1
- package/dist/api/types.d.ts +318 -86
- package/dist/api/types.js +9 -3
- package/dist/auth-token.d.ts +1 -1
- package/dist/auth-token.js +2 -2
- package/dist/{chunk-HMTT2PX4.js → chunk-2LZDP26O.js} +8 -5
- package/dist/chunk-2LZDP26O.js.map +1 -0
- package/dist/{chunk-LPPVGHYD.js → chunk-BL4U7GYY.js} +2 -2
- package/dist/chunk-BL4U7GYY.js.map +1 -0
- package/dist/{chunk-5Z4UCHDB.js → chunk-D3JNA6ZI.js} +69 -6
- package/dist/chunk-D3JNA6ZI.js.map +1 -0
- package/dist/{chunk-TOOTEEM4.js → chunk-G7GY7PJU.js} +1 -1
- package/dist/chunk-G7GY7PJU.js.map +1 -0
- package/dist/chunk-GGIINEEK.js +3401 -0
- package/dist/chunk-GGIINEEK.js.map +1 -0
- package/dist/chunk-HDBZRVA5.js +29 -0
- package/dist/chunk-HDBZRVA5.js.map +1 -0
- package/dist/{chunk-NVWAYYRX.js → chunk-HPOHMZCH.js} +686 -531
- package/dist/chunk-HPOHMZCH.js.map +1 -0
- package/dist/{chunk-QFV3ERQO.js → chunk-OQZDL3TR.js} +2 -2
- package/dist/hmr/manifest.js +1 -1
- package/dist/index.js +9351 -5551
- package/dist/index.js.map +1 -1
- package/dist/machine-status.js +2 -2
- package/dist/model-catalog.d.ts +610 -0
- package/dist/model-catalog.js +108 -0
- package/dist/model-catalog.js.map +1 -0
- package/dist/plugin/index.d.ts +141 -78
- package/dist/reset-admin-password.js +3 -4
- package/dist/reset-admin-password.js.map +1 -1
- package/dist/version-report.js +2 -2
- package/package.json +15 -14
- package/web-dist/assets/{abnfDiagram-VCTEODGH-CKWh4hoA.js → abnfDiagram-VCTEODGH-DMzkb4l4.js} +1 -1
- package/web-dist/assets/{addon-clipboard-DxLqat7q.js → addon-clipboard-BtD2BVSi.js} +1 -1
- package/web-dist/assets/{addon-fit-B8oPGPkW.js → addon-fit-Dr6HWsi_.js} +1 -1
- package/web-dist/assets/{addon-web-links-C0W5TcK0.js → addon-web-links-VEE3YLzQ.js} +1 -1
- package/web-dist/assets/{arc-Dp9EkaAH.js → arc-CvMPOU_B.js} +1 -1
- package/web-dist/assets/{architectureDiagram-5GKGNRK7-BQ3LUcle.js → architectureDiagram-5GKGNRK7-Bk65mIBz.js} +1 -1
- package/web-dist/assets/{blockDiagram-I7D4REHJ-CZzyU0Jj.js → blockDiagram-I7D4REHJ-D54-XSNs.js} +1 -1
- package/web-dist/assets/{c4Diagram-7LVT6UL2-Qps3jeAu.js → c4Diagram-7LVT6UL2-Dm954LSt.js} +1 -1
- package/web-dist/assets/channel-CVHI75kz.js +1 -0
- package/web-dist/assets/{chunk-2Q5K7J3B-CQMntzuX.js → chunk-2Q5K7J3B-D_LJ_7Cn.js} +1 -1
- package/web-dist/assets/{chunk-5VM5RSS4-2H2ZJAte.js → chunk-5VM5RSS4-B8RI4tl3.js} +1 -1
- package/web-dist/assets/{chunk-F27PBJKO-B30xCNBv.js → chunk-F27PBJKO-aVSYH9JV.js} +1 -1
- package/web-dist/assets/{chunk-IMKFNOWR-Cyfx4zDs.js → chunk-IMKFNOWR-DnkdWeq2.js} +1 -1
- package/web-dist/assets/{chunk-JWPE2WC7-DADUytTh.js → chunk-JWPE2WC7-C2pJzKuW.js} +1 -1
- package/web-dist/assets/{chunk-POPQ4Y6H-9bjsAxLS.js → chunk-POPQ4Y6H-Bx7Iikzx.js} +1 -1
- package/web-dist/assets/{chunk-SVP7TREG-B9xO1VpN.js → chunk-SVP7TREG-qJC2SMqr.js} +1 -1
- package/web-dist/assets/{chunk-TICWLB2K-B6oDkrep.js → chunk-TICWLB2K-Crl_BgKI.js} +1 -1
- package/web-dist/assets/{chunk-XXDRQBXY-BbU8DR4B.js → chunk-XXDRQBXY-BFFemuGm.js} +1 -1
- package/web-dist/assets/classDiagram-ZZMXUADV-CBdzAPFu.js +1 -0
- package/web-dist/assets/classDiagram-v2-VYDZK3BY-CBdzAPFu.js +1 -0
- package/web-dist/assets/{cose-bilkent-JH36ORCC-pkbuyec-.js → cose-bilkent-JH36ORCC-Bmh2Kx7h.js} +1 -1
- package/web-dist/assets/{cynefin-OW5HDTMX-DU9YQQnc.js → cynefin-OW5HDTMX-BveNWnUV.js} +1 -1
- package/web-dist/assets/{cynefinDiagram-5FMLGOSQ-3a3XuhQq.js → cynefinDiagram-5FMLGOSQ-85itazbJ.js} +1 -1
- package/web-dist/assets/{dagre-GXQ25YYZ-BfZ9C5CZ.js → dagre-GXQ25YYZ-DIZT-KY_.js} +1 -1
- package/web-dist/assets/{diagram-S7CK7UJ4-BoGhVBMn.js → diagram-S7CK7UJ4-Dum-g63f.js} +1 -1
- package/web-dist/assets/{diagram-UQ7AKVKN-zE67quty.js → diagram-UQ7AKVKN-DallMKJl.js} +1 -1
- package/web-dist/assets/{diagram-VSXAHHWV-NihoXeXm.js → diagram-VSXAHHWV-DWELqxfN.js} +1 -1
- package/web-dist/assets/{diagram-VX7I27RA-1627np0S.js → diagram-VX7I27RA-BNs0gB9E.js} +1 -1
- package/web-dist/assets/{diagram-Z3DM3KII-Ddg_bUan.js → diagram-Z3DM3KII-C9mF9xAd.js} +1 -1
- package/web-dist/assets/{ebnfDiagram-PWID7BFC-CmPUOM4j.js → ebnfDiagram-PWID7BFC-D_Diy9HH.js} +1 -1
- package/web-dist/assets/{erDiagram-RLTQ6QDP-DiTnEK72.js → erDiagram-RLTQ6QDP-4QTS5VIh.js} +1 -1
- package/web-dist/assets/{flowDiagram-HODETNUW-mGQ38tRM.js → flowDiagram-HODETNUW-CliTRrtV.js} +1 -1
- package/web-dist/assets/{ganttDiagram-EL5Y4UJY-DU7MlzpW.js → ganttDiagram-EL5Y4UJY-DCRd-Ss4.js} +1 -1
- package/web-dist/assets/{gitGraphDiagram-WWUBYQGX-CRE_3vjl.js → gitGraphDiagram-WWUBYQGX-CTz13GjS.js} +1 -1
- package/web-dist/assets/highlighter-BScH1Ud3.js +2 -0
- package/web-dist/assets/{highlighter-core-B5CvXcEK.js → highlighter-core-DKz8Z6-I.js} +1 -1
- package/web-dist/assets/{highlighter.worker-D6D9fPma.js → highlighter.worker-CKF6ok1g.js} +1 -1
- package/web-dist/assets/index-BUvJ5Ctt.js +1299 -0
- package/web-dist/assets/index-DRj7nvPY.css +1 -0
- package/web-dist/assets/{infoDiagram-27XIBGKW-Cdo6Ar-a.js → infoDiagram-27XIBGKW-DNfTEPAV.js} +1 -1
- package/web-dist/assets/{ishikawaDiagram-5VMMS53U-C27pgect.js → ishikawaDiagram-5VMMS53U-B7aCjjG1.js} +1 -1
- package/web-dist/assets/{journeyDiagram-3NMN7TZE-CVxtKpLG.js → journeyDiagram-3NMN7TZE-COmDALVr.js} +1 -1
- package/web-dist/assets/{kanban-definition-UXKFOSKX-DWA31Da7.js → kanban-definition-UXKFOSKX-BzUfPINh.js} +1 -1
- package/web-dist/assets/{layout-DWKCU9PO.js → layout-BwzuPU4M.js} +1 -1
- package/web-dist/assets/{linear-BwCthj_k.js → linear-WPZTYjz3.js} +1 -1
- package/web-dist/assets/mermaid.core-zKzlwpkw.js +310 -0
- package/web-dist/assets/{mindmap-definition-YA3MSWOX-BLEDhbn1.js → mindmap-definition-YA3MSWOX-DCRg7eAx.js} +1 -1
- package/web-dist/assets/{pegDiagram-XKGWAZYB-CLEM1SDU.js → pegDiagram-XKGWAZYB-DyphzDLU.js} +1 -1
- package/web-dist/assets/{pieDiagram-E7YTZNPT-B__g6eF0.js → pieDiagram-E7YTZNPT-BI5DueGA.js} +1 -1
- package/web-dist/assets/powershell-DXrKVo3G.js +1 -0
- package/web-dist/assets/powershell-Dpen1YoG.js +1 -0
- package/web-dist/assets/{quadrantDiagram-AXDQQJYC-C7VQ2LMY.js → quadrantDiagram-AXDQQJYC-GZxSMY2Z.js} +1 -1
- package/web-dist/assets/{railroadDiagram-O6MQD6OU-eWNnzjwS.js → railroadDiagram-O6MQD6OU-DEFCigBf.js} +1 -1
- package/web-dist/assets/{requirementDiagram-BXWQKSXE-Bi5qeD4b.js → requirementDiagram-BXWQKSXE-Br0thc4j.js} +1 -1
- package/web-dist/assets/{sankeyDiagram-P5KCCOFB-DG1VOpXX.js → sankeyDiagram-P5KCCOFB-DYf0pY67.js} +1 -1
- package/web-dist/assets/{sequenceDiagram-WJ2MYXX4-Fhkw73Th.js → sequenceDiagram-WJ2MYXX4-1EFMboX4.js} +1 -1
- package/web-dist/assets/{sizeCapture-INFHLROL-D9E_17SF.js → sizeCapture-INFHLROL-BuOFv-5q.js} +1 -1
- package/web-dist/assets/{stateDiagram-D77RDMKH-Cs5Y0xhW.js → stateDiagram-D77RDMKH-Bg3H1tjX.js} +1 -1
- package/web-dist/assets/stateDiagram-v2-MP3YSRHH-DfaDaPAd.js +1 -0
- package/web-dist/assets/{swimlanes-42K2YHIH-t-EocvKZ.js → swimlanes-42K2YHIH-BuOa6Osb.js} +1 -1
- package/web-dist/assets/swimlanesDiagram-VR7AAH4N-BopAC7b8.js +8 -0
- package/web-dist/assets/{timeline-definition-24CTP7MA-CozGCxuA.js → timeline-definition-24CTP7MA-BDekfdDT.js} +1 -1
- package/web-dist/assets/{vennDiagram-4TSXK5OY-ewU8ycSa.js → vennDiagram-4TSXK5OY-DnC09UI4.js} +1 -1
- package/web-dist/assets/{wardleyDiagram-VM6X3IG4-DuAhDu-0.js → wardleyDiagram-VM6X3IG4-BXKvxuZQ.js} +1 -1
- package/web-dist/assets/{xterm-v5e56GC9.js → xterm-oY4DYg5s.js} +1 -1
- package/web-dist/assets/{xychartDiagram-S5SC5T6Z-BxGuZO23.js → xychartDiagram-S5SC5T6Z-DdDC1VLb.js} +1 -1
- package/web-dist/index.html +12 -6
- package/web-dist/{workflow-ui.css → workbench-ui.css} +72 -67
- package/dist/benchmark-package.d.ts +0 -39
- package/dist/benchmark-package.js +0 -97
- package/dist/benchmark-package.js.map +0 -1
- package/dist/chunk-54E64LWB.js +0 -63
- package/dist/chunk-54E64LWB.js.map +0 -1
- package/dist/chunk-5Z4UCHDB.js.map +0 -1
- package/dist/chunk-HMTT2PX4.js.map +0 -1
- package/dist/chunk-JKJXQTHH.js +0 -19
- package/dist/chunk-JKJXQTHH.js.map +0 -1
- package/dist/chunk-LPPVGHYD.js.map +0 -1
- package/dist/chunk-NVWAYYRX.js.map +0 -1
- package/dist/chunk-Q4ON2L6E.js +0 -52
- package/dist/chunk-Q4ON2L6E.js.map +0 -1
- package/dist/chunk-TOOTEEM4.js.map +0 -1
- package/web-dist/assets/channel--PblgLlC.js +0 -1
- package/web-dist/assets/classDiagram-ZZMXUADV-mn8tbuqa.js +0 -1
- package/web-dist/assets/classDiagram-v2-VYDZK3BY-mn8tbuqa.js +0 -1
- package/web-dist/assets/highlighter-CzfXQ6yd.js +0 -2
- package/web-dist/assets/index-BdG4qRyI.js +0 -1275
- package/web-dist/assets/index-W-xYFoIm.css +0 -1
- package/web-dist/assets/mermaid.core-BcTrmPOv.js +0 -310
- package/web-dist/assets/stateDiagram-v2-MP3YSRHH-CEM51fAf.js +0 -1
- package/web-dist/assets/swimlanesDiagram-VR7AAH4N-BrR66vrK.js +0 -8
- /package/dist/{chunk-QFV3ERQO.js.map → chunk-OQZDL3TR.js.map} +0 -0
package/dist/api/types.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
|
+
API_SOCKET_PATH,
|
|
2
3
|
MACHINE_PHASES,
|
|
3
|
-
TICKET_SLUG_PATTERN
|
|
4
|
-
|
|
4
|
+
TICKET_SLUG_PATTERN,
|
|
5
|
+
apiSocketPath,
|
|
6
|
+
sessionKindPath
|
|
7
|
+
} from "../chunk-HDBZRVA5.js";
|
|
5
8
|
import "../chunk-B5IP37ZQ.js";
|
|
6
9
|
export {
|
|
10
|
+
API_SOCKET_PATH,
|
|
7
11
|
MACHINE_PHASES,
|
|
8
|
-
TICKET_SLUG_PATTERN
|
|
12
|
+
TICKET_SLUG_PATTERN,
|
|
13
|
+
apiSocketPath,
|
|
14
|
+
sessionKindPath
|
|
9
15
|
};
|
|
10
16
|
//# sourceMappingURL=types.js.map
|
package/dist/auth-token.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** How a session was established, as stored. NULL on rows formed before the column existed. */
|
|
2
|
-
type SessionViaValue = "password" | "desktop" | "setup" | "cli";
|
|
2
|
+
type SessionViaValue = "password" | "desktop" | "setup" | "cli" | "remote";
|
|
3
3
|
|
|
4
4
|
/** An hour: long enough for a controller to finish, short enough to forget. */
|
|
5
5
|
declare const CLI_TOKEN_TTL_MS: number;
|
package/dist/auth-token.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AuthSessionsRepo,
|
|
3
3
|
UsersRepo
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-G7GY7PJU.js";
|
|
5
5
|
import {
|
|
6
6
|
openExistingDatabase
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-HPOHMZCH.js";
|
|
8
8
|
import "./chunk-B5IP37ZQ.js";
|
|
9
9
|
|
|
10
10
|
// src/auth-token.ts
|
|
@@ -50,12 +50,13 @@ var MachinesRepo = class {
|
|
|
50
50
|
installedAt: null,
|
|
51
51
|
sessionPid: null,
|
|
52
52
|
remotePort: null,
|
|
53
|
-
platform: null
|
|
53
|
+
platform: null,
|
|
54
|
+
adoptedAt: null
|
|
54
55
|
},
|
|
55
56
|
...patch
|
|
56
57
|
};
|
|
57
58
|
this.db.prepare(
|
|
58
|
-
"INSERT OR REPLACE INTO machines (address, machine_id, version, installed_at, session_pid, remote_port, platform) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
|
59
|
+
"INSERT OR REPLACE INTO machines (address, machine_id, version, installed_at, session_pid, remote_port, platform, adopted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
59
60
|
).run(
|
|
60
61
|
address,
|
|
61
62
|
next.machineId,
|
|
@@ -63,7 +64,8 @@ var MachinesRepo = class {
|
|
|
63
64
|
next.installedAt,
|
|
64
65
|
next.sessionPid,
|
|
65
66
|
next.remotePort,
|
|
66
|
-
next.platform
|
|
67
|
+
next.platform,
|
|
68
|
+
next.adoptedAt
|
|
67
69
|
);
|
|
68
70
|
}
|
|
69
71
|
/** The addresses a Project uses, or null when it has never had a list written for it. */
|
|
@@ -83,11 +85,12 @@ function toRow(row) {
|
|
|
83
85
|
installedAt: row.installed_at ?? null,
|
|
84
86
|
sessionPid: row.session_pid ?? null,
|
|
85
87
|
remotePort: row.remote_port ?? null,
|
|
86
|
-
platform: row.platform ?? null
|
|
88
|
+
platform: row.platform ?? null,
|
|
89
|
+
adoptedAt: row.adopted_at ?? null
|
|
87
90
|
};
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
export {
|
|
91
94
|
MachinesRepo
|
|
92
95
|
};
|
|
93
|
-
//# sourceMappingURL=chunk-
|
|
96
|
+
//# sourceMappingURL=chunk-2LZDP26O.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/repos/machines.ts"],"sourcesContent":["/**\n * What this server remembers about machines: its own identity, one row per machine it has\n * installed on or reached (what is installed there, the session held to it), and which\n * machines each Project uses. All in web.db, so a hot swap or a restart reads back exactly\n * what the last generation wrote — the JSON file this replaces could not survive a schema\n * change, and nothing else this server remembers lives outside the database.\n */\nimport type { DatabaseSync } from \"node:sqlite\";\nimport { randomBytes } from \"node:crypto\";\n\nexport interface MachineRow {\n /** `ssh:<alias>` */\n address: string;\n /** That machine's own id, once heard. */\n machineId: string | null;\n /** What this server last installed there; null when it never has. */\n version: string | null;\n installedAt: string | null;\n /**\n * Non-null while a connection to it is HELD: the pid of the session as of the last connect.\n * A record of intent, not a handle — a restart or a hot push re-holds every machine with one,\n * and a disconnect clears it. It is never used to kill anything: a pid read back from a file\n * may by then be anyone's.\n */\n sessionPid: number | null;\n /** The port its server was bound to over there, as of the last connect. */\n remotePort: number | null;\n /** What the install found the machine to be; null until one has. The status probe speaks that dialect. */\n platform: \"linux\" | \"darwin\" | \"win32\" | null;\n /**\n * When this server adopted the machine: when it first recorded it at this address, again when\n * the address answers with another machine's id. The Sessions that machine held before then\n * read as CLI tasks through this server (runtime/session-sources.ts). Null for a machine\n * recorded before the time was kept, which never gets one and which the rule leaves alone.\n */\n adoptedAt: string | null;\n}\n\n/** Everything but the address may be patched; absent fields keep their value. */\ntype MachinePatch = Partial<Omit<MachineRow, \"address\">>;\n\n/**\n * 12 random bytes as base64url: 16 characters a person can read in a tooltip, 96 bits\n * against ids minted on machines that never coordinate.\n */\nconst MACHINE_ID_BYTES = 12;\n\nexport class MachinesRepo {\n constructor(private readonly db: DatabaseSync) {}\n\n /**\n * This server's own id if one has been minted, WITHOUT minting one — `penguin server\n * status` runs on a data root whose server may never have started, and must not create an\n * identity as a side effect of the question.\n */\n peekOwnId(): string | null {\n const row = this.db.prepare(\"SELECT machine_id FROM machine WHERE singleton = 1\").get();\n return row ? (row.machine_id as string) : null;\n }\n\n /** This server's own id, minted on first call and stable ever after. */\n ownId(): string {\n const existing = this.peekOwnId();\n if (existing !== null) return existing;\n const minted = randomBytes(MACHINE_ID_BYTES).toString(\"base64url\");\n this.db.prepare(\"INSERT INTO machine (singleton, machine_id) VALUES (1, ?)\").run(minted);\n return minted;\n }\n\n get(address: string): MachineRow | null {\n const row = this.db.prepare(\"SELECT * FROM machines WHERE address = ?\").get(address);\n return row === undefined ? null : toRow(row);\n }\n\n /**\n * Every row answering to a machine's own id — two aliases for one host are two rows with\n * one id. Newest install first, then by address, so the order is the same every time; which\n * of them to speak through is the service's to decide (it knows which has a session).\n */\n byMachineId(machineId: string): MachineRow[] {\n return this.db\n .prepare(\n \"SELECT * FROM machines WHERE machine_id = ? ORDER BY installed_at DESC, address ASC\",\n )\n .all(machineId)\n .map(toRow);\n }\n\n all(): MachineRow[] {\n return this.db.prepare(\"SELECT * FROM machines\").all().map(toRow);\n }\n\n /** Writes the fields given, creating the row when there is none. */\n patch(address: string, patch: MachinePatch): void {\n const next: MachineRow = {\n ...(this.get(address) ?? {\n address,\n machineId: null,\n version: null,\n installedAt: null,\n sessionPid: null,\n remotePort: null,\n platform: null,\n adoptedAt: null,\n }),\n ...patch,\n };\n this.db\n .prepare(\n \"INSERT OR REPLACE INTO machines (address, machine_id, version, installed_at, session_pid, remote_port, platform, adopted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\",\n )\n .run(\n address,\n next.machineId,\n next.version,\n next.installedAt,\n next.sessionPid,\n next.remotePort,\n next.platform,\n next.adoptedAt,\n );\n }\n\n /** The addresses a Project uses, or null when it has never had a list written for it. */\n members(projectId: string): string[] | null {\n const row = this.db\n .prepare(\"SELECT addresses FROM machine_project WHERE project_id = ?\")\n .get(projectId);\n return row ? (JSON.parse(row.addresses as string) as string[]) : null;\n }\n\n setMembers(projectId: string, addresses: string[]): void {\n this.db\n .prepare(\"INSERT OR REPLACE INTO machine_project (project_id, addresses) VALUES (?, ?)\")\n .run(projectId, JSON.stringify(addresses));\n }\n}\n\nfunction toRow(row: Record<string, unknown>): MachineRow {\n return {\n address: row.address as string,\n machineId: (row.machine_id as string | null) ?? null,\n version: (row.version as string | null) ?? null,\n installedAt: (row.installed_at as string | null) ?? null,\n sessionPid: (row.session_pid as number | null) ?? null,\n remotePort: (row.remote_port as number | null) ?? null,\n platform: (row.platform as MachineRow[\"platform\"]) ?? null,\n adoptedAt: (row.adopted_at as string | null) ?? null,\n };\n}\n"],"mappings":";AAQA,SAAS,mBAAmB;AAqC5B,IAAM,mBAAmB;AAElB,IAAM,eAAN,MAAmB;AAAA,EACxB,YAA6B,IAAkB;AAAlB;AAAA,EAAmB;AAAA,EAAnB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAO7B,YAA2B;AACzB,UAAM,MAAM,KAAK,GAAG,QAAQ,oDAAoD,EAAE,IAAI;AACtF,WAAO,MAAO,IAAI,aAAwB;AAAA,EAC5C;AAAA;AAAA,EAGA,QAAgB;AACd,UAAM,WAAW,KAAK,UAAU;AAChC,QAAI,aAAa,KAAM,QAAO;AAC9B,UAAM,SAAS,YAAY,gBAAgB,EAAE,SAAS,WAAW;AACjE,SAAK,GAAG,QAAQ,2DAA2D,EAAE,IAAI,MAAM;AACvF,WAAO;AAAA,EACT;AAAA,EAEA,IAAI,SAAoC;AACtC,UAAM,MAAM,KAAK,GAAG,QAAQ,0CAA0C,EAAE,IAAI,OAAO;AACnF,WAAO,QAAQ,SAAY,OAAO,MAAM,GAAG;AAAA,EAC7C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,YAAY,WAAiC;AAC3C,WAAO,KAAK,GACT;AAAA,MACC;AAAA,IACF,EACC,IAAI,SAAS,EACb,IAAI,KAAK;AAAA,EACd;AAAA,EAEA,MAAoB;AAClB,WAAO,KAAK,GAAG,QAAQ,wBAAwB,EAAE,IAAI,EAAE,IAAI,KAAK;AAAA,EAClE;AAAA;AAAA,EAGA,MAAM,SAAiB,OAA2B;AAChD,UAAM,OAAmB;AAAA,MACvB,GAAI,KAAK,IAAI,OAAO,KAAK;AAAA,QACvB;AAAA,QACA,WAAW;AAAA,QACX,SAAS;AAAA,QACT,aAAa;AAAA,QACb,YAAY;AAAA,QACZ,YAAY;AAAA,QACZ,UAAU;AAAA,QACV,WAAW;AAAA,MACb;AAAA,MACA,GAAG;AAAA,IACL;AACA,SAAK,GACF;AAAA,MACC;AAAA,IACF,EACC;AAAA,MACC;AAAA,MACA,KAAK;AAAA,MACL,KAAK;AAAA,MACL,KAAK;AAAA,MACL,KAAK;AAAA,MACL,KAAK;AAAA,MACL,KAAK;AAAA,MACL,KAAK;AAAA,IACP;AAAA,EACJ;AAAA;AAAA,EAGA,QAAQ,WAAoC;AAC1C,UAAM,MAAM,KAAK,GACd,QAAQ,4DAA4D,EACpE,IAAI,SAAS;AAChB,WAAO,MAAO,KAAK,MAAM,IAAI,SAAmB,IAAiB;AAAA,EACnE;AAAA,EAEA,WAAW,WAAmB,WAA2B;AACvD,SAAK,GACF,QAAQ,8EAA8E,EACtF,IAAI,WAAW,KAAK,UAAU,SAAS,CAAC;AAAA,EAC7C;AACF;AAEA,SAAS,MAAM,KAA0C;AACvD,SAAO;AAAA,IACL,SAAS,IAAI;AAAA,IACb,WAAY,IAAI,cAAgC;AAAA,IAChD,SAAU,IAAI,WAA6B;AAAA,IAC3C,aAAc,IAAI,gBAAkC;AAAA,IACpD,YAAa,IAAI,eAAiC;AAAA,IAClD,YAAa,IAAI,eAAiC;AAAA,IAClD,UAAW,IAAI,YAAuC;AAAA,IACtD,WAAY,IAAI,cAAgC;AAAA,EAClD;AACF;","names":[]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// ../../node_modules/.pnpm/@prismshadow+penguin-hmr@file+packages+hmr_supports-color@10.2.2_ws@8.21.
|
|
1
|
+
// ../../node_modules/.pnpm/@prismshadow+penguin-hmr@file+packages+hmr_supports-color@10.2.2_ws@8.21.0_zod@4.6.7/node_modules/@prismshadow/penguin-hmr/dist/chunk-HC5WPFTZ.js
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import fsp from "fs/promises";
|
|
4
4
|
import path from "path";
|
|
@@ -80,4 +80,4 @@ export {
|
|
|
80
80
|
readPushedCli,
|
|
81
81
|
resolveCliBundlePath
|
|
82
82
|
};
|
|
83
|
-
//# sourceMappingURL=chunk-
|
|
83
|
+
//# sourceMappingURL=chunk-BL4U7GYY.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../node_modules/.pnpm/@prismshadow+penguin-hmr@file+packages+hmr_supports-color@10.2.2_ws@8.21.0_zod@4.6.7/node_modules/@prismshadow/penguin-hmr/src/manifest.ts"],"sourcesContent":["/**\n * The harness.json shape (`<root>/hmr/harness.json`) and pure readers over it.\n *\n * Split out of host.ts so a reader never needs a running HmrHost: `penguin` is a\n * separate, short-lived process per invocation (see packages/cli/src/index.ts) — it\n * has no host instance to ask, only the data root's own harness.json on disk.\n * `resolveCliBundlePath` is that reader: a plain disk read, no HTTP, no boot, no\n * in-memory state.\n *\n * ONE atomic version, THREE independent artifacts: `platform`, `cli`, and `web` are\n * always written together by HmrHost's single merged upgrade path (see host.ts's\n * persistVersion), but each is content-addressed and stored on its own — a push to\n * POST /api/hmr/upgrade carries `platform` and `cli` as two separate single-file ESM\n * sources, so `cli.bundle` points at its OWN file, a different sha from\n * `platform.bundle`. They are read back together too (see host.ts's restore): a\n * partial record (e.g. `platform` present but `web` or `cli` missing) is never\n * trusted — the whole version is treated as absent instead of partially applied.\n */\nimport fs from \"node:fs\";\nimport fsp from \"node:fs/promises\";\nimport path from \"node:path\";\nimport type { HarnessInfo } from \"@prismshadow/penguin-core\";\n\n/**\n * The committed on-disk record: a runtime restart resumes exactly this CODE (platform,\n * cli, web). It carries no STATE — a restart always boots the resumed platform bundle\n * against its own fresh initial context (bundle.context, see host.ts's PlatformBundle),\n * never a document parked from a previous run (see host.ts's module doc for why: a\n * parked doc's live-resource handles die with the process anyway, so resuming one could\n * only ever produce handles that fail to reclaim). Paths are relative to hmrDir\n * (`<root>/hmr`).\n */\n/**\n * Written into an assets directory once every file in it is on disk. Its absence marks a\n * directory whose materialization was interrupted; no asset path can collide with it\n * (assets arrive as `node_modules/...` paths).\n */\nexport const MATERIALIZED = \".materialized\";\n\nexport interface Manifest {\n platform?: { bundle: string };\n /**\n * The CLI's own bundle pointer — its own independent file (a different sha from\n * `platform.bundle`; the two are separately compiled artifacts), kept as its own\n * field so a reader that only wants \"which bundle does the CLI load right now\"\n * never has to reach into `platform` at all.\n */\n cli?: { bundle: string };\n /** One gzip(JSON.stringify({ files })) artifact, restored straight into memory. */\n web?: { manifest: string };\n /**\n * Files the pushed platform needs as REAL files on disk, unpacked under this directory\n * (relative to hmrDir). The web artifact stays in memory because it is only ever served\n * as bytes; an asset is something the platform hands to the OS — a native `.node` the\n * loader must resolve by path, a helper binary it execs — so it has to exist on the\n * filesystem, with its exec bit intact.\n */\n assets?: { dir: string };\n /**\n * Provenance of the push that produced this version, exactly as the pushing client sent\n * it (scripts/deploy.mjs fills it from its own checkout). Recorded, never executed or\n * resolved. Absent for a version pushed without it, and for every version committed\n * before the field existed — which is why every reader treats it as optional.\n */\n source?: { repo: string; revision: string };\n /** When this version was committed to the store (ISO 8601). Absent on older records. */\n pushedAt?: string;\n /**\n * The sha256 of each part this version was pushed as (platform, cli, every web file):\n * the blobs the store keeps alive so the next push need not carry an unchanged one.\n * Absent on older records.\n */\n blobs?: string[];\n}\n\n/** A string field of an untrusted manifest, or null unless it is a non-empty string. */\nfunction str(value: unknown): string | null {\n return typeof value === \"string\" && value.length > 0 ? value : null;\n}\n\n/**\n * What the store has committed, for version reporting: provenance, commit time, and the\n * three artifact pointers. Null when nothing has ever been pushed to this root.\n *\n * Read defensively rather than cast: harness.json is written only by persistVersion, but a\n * truncated or hand-edited file must degrade a `penguin version` to missing fields, never\n * crash it. A record missing every artifact pointer counts as nothing pushed, and so does an\n * unreadable one — the only reader here that deliberately collapses the two, because its\n * caller is a report with nowhere to put a fault. The host says so at boot instead, and\n * `penguin-hmr` says so through readPushedCli.\n */\nexport async function readHarnessInfo(root: string): Promise<HarnessInfo | null> {\n const manifest = await readManifest(root);\n if (manifest === null) return null;\n\n const bundles = {\n platform: str(manifest.platform?.bundle),\n cli: str(manifest.cli?.bundle),\n web: str(manifest.web?.manifest),\n };\n if (bundles.platform === null && bundles.cli === null && bundles.web === null) return null;\n\n const repo = str(manifest.source?.repo);\n const revision = str(manifest.source?.revision);\n return {\n // Both halves or neither: half a provenance names nothing.\n source: repo !== null && revision !== null ? { repo, revision } : null,\n pushedAt: str(manifest.pushedAt),\n bundles,\n };\n}\n\n/**\n * `<root>/hmr/harness.json`, as one of three answers. No file means nothing was ever pushed\n * here; a file that cannot be read or parsed is a fault on a root that DID commit a version,\n * and the two must not arrive as the same value — a reader that acts on \"nothing pushed\"\n * would quietly ignore a version this root still holds.\n */\ntype ManifestRead =\n { kind: \"none\" } | { kind: \"manifest\"; manifest: Manifest } | { kind: \"broken\"; reason: string };\n\nasync function readManifestFile(root: string): Promise<ManifestRead> {\n const file = path.join(root, \"hmr\", \"harness.json\");\n let raw: string;\n try {\n raw = await fsp.readFile(file, \"utf8\");\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === \"ENOENT\") return { kind: \"none\" };\n return { kind: \"broken\", reason: `${file} could not be read: ${msg(err)}` };\n }\n try {\n return { kind: \"manifest\", manifest: JSON.parse(raw) as Manifest };\n } catch (err) {\n return { kind: \"broken\", reason: `${file} is not readable JSON: ${msg(err)}` };\n }\n}\n\nfunction msg(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\n/**\n * Reads and parses `<root>/hmr/harness.json`; null when missing or corrupt. The published\n * `./hmr/manifest` subpath is a contract with installed CLIs, so this keeps its shape;\n * a caller that has to tell the two apart reads it through readPushedCli.\n */\nexport async function readManifest(root: string): Promise<Manifest | null> {\n const read = await readManifestFile(root);\n return read.kind === \"manifest\" ? read.manifest : null;\n}\n\n/**\n * What this root has for the `penguin-hmr` loader. Three answers, never two: a root that\n * pushed no CLI and a root whose record names one it cannot produce are different\n * situations, and collapsing them tells the caller \"nothing pushed\" when the truth is\n * \"the store lost it\".\n */\nexport type PushedCli =\n /** Nothing has pushed a CLI here: a fresh root, or a record with no `cli` entry. */\n | { kind: \"none\" }\n /** The committed CLI bundle, absolute. */\n | { kind: \"bundle\"; file: string }\n /** The record names a CLI this root cannot produce. Says which, so it can be repaired. */\n | { kind: \"broken\"; reason: string };\n\n/**\n * Reads the committed CLI bundle. Never throws: a malformed record is an answer\n * (`broken`), not an exception, because the caller is a CLI entry point that has to print\n * something useful either way.\n *\n * `cli.bundle` is trusted content, written only by persistVersion, but a reader must never\n * let a malformed or malicious value walk it out of the store via `..` segments — an escape\n * is `broken`, like a file the store no longer holds (pruned, see host.ts's pruneStore).\n */\nexport async function readPushedCli(root: string): Promise<PushedCli> {\n const read = await readManifestFile(root);\n if (read.kind === \"none\") return { kind: \"none\" };\n if (read.kind === \"broken\") return { kind: \"broken\", reason: read.reason };\n // No `cli` entry at all is a version that pushed no CLI; an entry that names nothing\n // usable is a record this root cannot act on.\n if (read.manifest.cli === undefined) return { kind: \"none\" };\n const bundle: unknown = read.manifest.cli.bundle;\n if (typeof bundle !== \"string\" || bundle.length === 0) {\n return { kind: \"broken\", reason: \"harness.json has a `cli` entry with no bundle path\" };\n }\n const hmrDir = path.join(root, \"hmr\");\n const abs = path.resolve(hmrDir, bundle);\n if (abs !== hmrDir && !abs.startsWith(hmrDir + path.sep)) {\n return {\n kind: \"broken\",\n reason: `harness.json points \\`cli.bundle\\` outside ${hmrDir}: ${bundle}`,\n };\n }\n if (!fs.existsSync(abs)) {\n return { kind: \"broken\", reason: `the committed CLI bundle is missing from the store: ${abs}` };\n }\n return { kind: \"bundle\", file: abs };\n}\n\n/**\n * The committed CLI bundle's path, or null for anything else. The published `./hmr/manifest`\n * subpath is a contract with installed CLIs, so this keeps its shape; readPushedCli is what\n * says WHY there is no path.\n */\nexport async function resolveCliBundlePath(root: string): Promise<string | null> {\n const cli = await readPushedCli(root);\n return cli.kind === \"bundle\" ? cli.file : null;\n}\n"],"mappings":";AAkBA,OAAO,QAAQ;AACf,OAAO,SAAS;AAChB,OAAO,UAAU;AAiBV,IAAM,eAAe;AAuC5B,SAAS,IAAI,OAA+B;AAC1C,SAAO,OAAO,UAAU,YAAY,MAAM,SAAS,IAAI,QAAQ;AACjE;AAaA,eAAsB,gBAAgB,MAA2C;AAC/E,QAAM,WAAW,MAAM,aAAa,IAAI;AACxC,MAAI,aAAa,KAAM,QAAO;AAE9B,QAAM,UAAU;IACd,UAAU,IAAI,SAAS,UAAU,MAAM;IACvC,KAAK,IAAI,SAAS,KAAK,MAAM;IAC7B,KAAK,IAAI,SAAS,KAAK,QAAQ;EACjC;AACA,MAAI,QAAQ,aAAa,QAAQ,QAAQ,QAAQ,QAAQ,QAAQ,QAAQ,KAAM,QAAO;AAEtF,QAAM,OAAO,IAAI,SAAS,QAAQ,IAAI;AACtC,QAAM,WAAW,IAAI,SAAS,QAAQ,QAAQ;AAC9C,SAAO;;IAEL,QAAQ,SAAS,QAAQ,aAAa,OAAO,EAAE,MAAM,SAAS,IAAI;IAClE,UAAU,IAAI,SAAS,QAAQ;IAC/B;EACF;AACF;AAWA,eAAe,iBAAiB,MAAqC;AACnE,QAAM,OAAO,KAAK,KAAK,MAAM,OAAO,cAAc;AAClD,MAAI;AACJ,MAAI;AACF,UAAM,MAAM,IAAI,SAAS,MAAM,MAAM;EACvC,SAAS,KAAK;AACZ,QAAK,IAA8B,SAAS,SAAU,QAAO,EAAE,MAAM,OAAO;AAC5E,WAAO,EAAE,MAAM,UAAU,QAAQ,GAAG,IAAI,uBAAuB,IAAI,GAAG,CAAC,GAAG;EAC5E;AACA,MAAI;AACF,WAAO,EAAE,MAAM,YAAY,UAAU,KAAK,MAAM,GAAG,EAAc;EACnE,SAAS,KAAK;AACZ,WAAO,EAAE,MAAM,UAAU,QAAQ,GAAG,IAAI,0BAA0B,IAAI,GAAG,CAAC,GAAG;EAC/E;AACF;AAEA,SAAS,IAAI,KAAsB;AACjC,SAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AACxD;AAOA,eAAsB,aAAa,MAAwC;AACzE,QAAM,OAAO,MAAM,iBAAiB,IAAI;AACxC,SAAO,KAAK,SAAS,aAAa,KAAK,WAAW;AACpD;AAyBA,eAAsB,cAAc,MAAkC;AACpE,QAAM,OAAO,MAAM,iBAAiB,IAAI;AACxC,MAAI,KAAK,SAAS,OAAQ,QAAO,EAAE,MAAM,OAAO;AAChD,MAAI,KAAK,SAAS,SAAU,QAAO,EAAE,MAAM,UAAU,QAAQ,KAAK,OAAO;AAGzE,MAAI,KAAK,SAAS,QAAQ,OAAW,QAAO,EAAE,MAAM,OAAO;AAC3D,QAAM,SAAkB,KAAK,SAAS,IAAI;AAC1C,MAAI,OAAO,WAAW,YAAY,OAAO,WAAW,GAAG;AACrD,WAAO,EAAE,MAAM,UAAU,QAAQ,qDAAqD;EACxF;AACA,QAAM,SAAS,KAAK,KAAK,MAAM,KAAK;AACpC,QAAM,MAAM,KAAK,QAAQ,QAAQ,MAAM;AACvC,MAAI,QAAQ,UAAU,CAAC,IAAI,WAAW,SAAS,KAAK,GAAG,GAAG;AACxD,WAAO;MACL,MAAM;MACN,QAAQ,8CAA8C,MAAM,KAAK,MAAM;IACzE;EACF;AACA,MAAI,CAAC,GAAG,WAAW,GAAG,GAAG;AACvB,WAAO,EAAE,MAAM,UAAU,QAAQ,uDAAuD,GAAG,GAAG;EAChG;AACA,SAAO,EAAE,MAAM,UAAU,MAAM,IAAI;AACrC;AAOA,eAAsB,qBAAqB,MAAsC;AAC/E,QAAM,MAAM,MAAM,cAAc,IAAI;AACpC,SAAO,IAAI,SAAS,WAAW,IAAI,OAAO;AAC5C;","names":[]}
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
HttpError
|
|
3
|
-
} from "./chunk-Q4ON2L6E.js";
|
|
4
1
|
import {
|
|
5
2
|
sessionTokenHash
|
|
6
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-G7GY7PJU.js";
|
|
7
4
|
import {
|
|
8
5
|
__decorateElement,
|
|
9
6
|
__decoratorStart,
|
|
@@ -113,9 +110,53 @@ function tokensEqual(a, b) {
|
|
|
113
110
|
return timingSafeEqual2(digest(a), digest(b));
|
|
114
111
|
}
|
|
115
112
|
|
|
113
|
+
// src/http/errors.ts
|
|
114
|
+
var HttpError = class extends Error {
|
|
115
|
+
constructor(status, code, message, retryAfterSeconds, details) {
|
|
116
|
+
super(message);
|
|
117
|
+
this.status = status;
|
|
118
|
+
this.code = code;
|
|
119
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
120
|
+
this.details = details;
|
|
121
|
+
this.name = "HttpError";
|
|
122
|
+
}
|
|
123
|
+
status;
|
|
124
|
+
code;
|
|
125
|
+
retryAfterSeconds;
|
|
126
|
+
details;
|
|
127
|
+
};
|
|
128
|
+
function badRequest(message) {
|
|
129
|
+
return new HttpError(400, "bad_request", message);
|
|
130
|
+
}
|
|
131
|
+
function errorBody(code, message, details) {
|
|
132
|
+
return { error: { code, message, ...details !== void 0 ? { details } : {} } };
|
|
133
|
+
}
|
|
134
|
+
function isMissingCredential(err) {
|
|
135
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
136
|
+
return /missing credentials|api[_ ]?key/i.test(message);
|
|
137
|
+
}
|
|
138
|
+
function modelCredentialMissing(modelId) {
|
|
139
|
+
return new HttpError(
|
|
140
|
+
400,
|
|
141
|
+
"model_credential_missing",
|
|
142
|
+
`Model ${modelId} has no API key yet. Configure it on the Models page first.`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
function handleError(err, c) {
|
|
146
|
+
if (err instanceof HttpError) {
|
|
147
|
+
if (err.retryAfterSeconds !== void 0) {
|
|
148
|
+
c.header("Retry-After", String(err.retryAfterSeconds));
|
|
149
|
+
}
|
|
150
|
+
return c.json(errorBody(err.code, err.message, err.details), err.status);
|
|
151
|
+
}
|
|
152
|
+
console.error(`[server] Unhandled exception: ${err.stack ?? err.message}`);
|
|
153
|
+
return c.json(errorBody("internal", "Internal server error."), 500);
|
|
154
|
+
}
|
|
155
|
+
|
|
116
156
|
// src/auth/service.ts
|
|
117
157
|
import { Component as Component2, Use, Interface as Interface2 } from "@prismshadow/penguin-core/kernel";
|
|
118
158
|
var MIN_PASSWORD_LENGTH = 8;
|
|
159
|
+
var REMOTE_SESSION_TTL_MS = 60 * 6e4;
|
|
119
160
|
var ADMIN_USER_ID = "admin";
|
|
120
161
|
var LOGIN_FREE_ATTEMPTS = 5;
|
|
121
162
|
var LOGIN_BACKOFF_START_MS = 1e3;
|
|
@@ -302,6 +343,22 @@ var AuthService = class {
|
|
|
302
343
|
}
|
|
303
344
|
return { user: toUserInfo(row), token: this.issueSession(row.userId, "desktop") };
|
|
304
345
|
}
|
|
346
|
+
issueRemoteSession() {
|
|
347
|
+
const row = this.users.findById(ADMIN_USER_ID);
|
|
348
|
+
if (!row) {
|
|
349
|
+
throw new HttpError(500, "internal", "Built-in admin has not been seeded.");
|
|
350
|
+
}
|
|
351
|
+
const maxAgeSeconds = Math.floor(REMOTE_SESSION_TTL_MS / 1e3);
|
|
352
|
+
return {
|
|
353
|
+
...this.authSessions.issue({
|
|
354
|
+
userId: row.userId,
|
|
355
|
+
via: "remote",
|
|
356
|
+
now: this.clock.now(),
|
|
357
|
+
ttlMs: REMOTE_SESSION_TTL_MS
|
|
358
|
+
}),
|
|
359
|
+
maxAgeSeconds
|
|
360
|
+
};
|
|
361
|
+
}
|
|
305
362
|
/** Self password change (user settings): validates the old password first; the current session stays valid. */
|
|
306
363
|
async changePassword(userId, oldPassword, newPassword) {
|
|
307
364
|
const row = this.users.findById(userId);
|
|
@@ -379,7 +436,7 @@ var AuthService = class {
|
|
|
379
436
|
this.authSessions.touch(tokenHash, new Date(now.getTime() + this.sessionTtlMs).toISOString());
|
|
380
437
|
renewed = true;
|
|
381
438
|
}
|
|
382
|
-
const via = session.via === "desktop" ? "desktop" : session.via === "setup" ? "setup" : "password";
|
|
439
|
+
const via = session.via === "remote" ? "token" : session.via === "desktop" ? "desktop" : session.via === "setup" ? "setup" : "password";
|
|
383
440
|
return { user, via, renewed };
|
|
384
441
|
}
|
|
385
442
|
/**
|
|
@@ -423,6 +480,12 @@ InitialProjectProvisioner = __decorateElement(_init4, 0, "InitialProjectProvisio
|
|
|
423
480
|
__runInitializers(_init4, 1, InitialProjectProvisioner);
|
|
424
481
|
|
|
425
482
|
export {
|
|
483
|
+
HttpError,
|
|
484
|
+
badRequest,
|
|
485
|
+
errorBody,
|
|
486
|
+
isMissingCredential,
|
|
487
|
+
modelCredentialMissing,
|
|
488
|
+
handleError,
|
|
426
489
|
mintApiToken,
|
|
427
490
|
storeApiToken,
|
|
428
491
|
hashPassword,
|
|
@@ -435,4 +498,4 @@ export {
|
|
|
435
498
|
AuthService,
|
|
436
499
|
InitialProjectProvisioner
|
|
437
500
|
};
|
|
438
|
-
//# sourceMappingURL=chunk-
|
|
501
|
+
//# sourceMappingURL=chunk-D3JNA6ZI.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/auth/password.ts","../src/auth/service.ts","../src/auth/api-token.ts","../src/http/errors.ts"],"sourcesContent":["import { Component, Interface } from \"@prismshadow/penguin-core/kernel\";\n/**\n * Password hashing (slow-hash storage).\n *\n * Uses node:crypto's scrypt (built-in, no extra dependency, meets the same\n * slow-hash requirement as bcrypt/argon2). Storage format:\n * `scrypt$N$r$p$<salt b64>$<hash b64>` — parameters are stored alongside the hash,\n * so old hashes remain verifiable after future parameter tuning; comparison uses\n * timingSafeEqual to guard against timing side-channels, and a sign-in for an account\n * that does not exist costs the same derivation as a wrong password\n * (verifyAccountPassword).\n */\nimport { randomBytes, scrypt, timingSafeEqual } from \"node:crypto\";\n\n/**\n * Work factor for new hashes. `hashPassword` takes it as a defaulted argument so the server's\n * own test suite can hash at a token cost: almost every case there seeds an admin, provisions\n * users and logs them in, and one derivation at this strength costs on the order of 100ms.\n * Nothing on the production path passes the argument, and no configuration reaches it —\n * `buildAppDeps`, which carries the test override, is not part of this package's exports.\n */\nexport const SCRYPT_COST = 16384;\nconst SCRYPT_R = 8;\nconst SCRYPT_P = 1;\nconst SALT_BYTES = 16;\nconst KEY_BYTES = 64;\n\nfunction scryptAsync(\n password: string,\n salt: Buffer,\n keyLen: number,\n n: number,\n r: number,\n p: number,\n): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n scrypt(password, salt, keyLen, { N: n, r, p, maxmem: 128 * 1024 * 1024 }, (err, key) => {\n if (err) reject(err);\n else resolve(key);\n });\n });\n}\n\n/**\n * Generates a password hash in `scrypt$N$r$p$salt$hash` format. `cost` is scrypt's N and\n * is recorded in the hash, so `verifyPassword` re-derives at whatever cost produced the\n * stored string.\n */\nexport async function hashPassword(password: string, cost: number = SCRYPT_COST): Promise<string> {\n const salt = randomBytes(SALT_BYTES);\n const key = await scryptAsync(password, salt, KEY_BYTES, cost, SCRYPT_R, SCRYPT_P);\n return [\n \"scrypt\",\n String(cost),\n String(SCRYPT_R),\n String(SCRYPT_P),\n salt.toString(\"base64\"),\n key.toString(\"base64\"),\n ].join(\"$\");\n}\n\n/**\n * What checking a password against a stored string found. `match` and `mismatch` each cost one\n * scrypt derivation; `unverifiable` — not the `scrypt$N$r$p$salt$hash` format, or parameters\n * scrypt refuses — cost none.\n */\nexport type PasswordCheck = \"match\" | \"mismatch\" | \"unverifiable\";\n\n/** Checks a password against a stored hash. Never throws. */\nexport async function checkPassword(password: string, stored: string): Promise<PasswordCheck> {\n const parts = stored.split(\"$\");\n if (parts.length !== 6 || parts[0] !== \"scrypt\") return \"unverifiable\";\n const n = Number(parts[1]);\n const r = Number(parts[2]);\n const p = Number(parts[3]);\n if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return \"unverifiable\";\n const salt = Buffer.from(parts[4]!, \"base64\");\n const expected = Buffer.from(parts[5]!, \"base64\");\n if (salt.length === 0 || expected.length === 0) return \"unverifiable\";\n let actual: Buffer;\n try {\n actual = await scryptAsync(password, salt, expected.length, n, r, p);\n } catch {\n return \"unverifiable\";\n }\n return timingSafeEqual(actual, expected) ? \"match\" : \"mismatch\";\n}\n\n/** Verifies a password; returns false if the stored string has an invalid format (never throws, so the login path can uniformly treat it as a credential error). */\nexport async function verifyPassword(password: string, stored: string): Promise<boolean> {\n return (await checkPassword(password, stored)) === \"match\";\n}\n\n/**\n * Verifies a sign-in password for an account that may not exist, at the cost a real account's\n * check has. `stored` is the account's hash, or null when there is no such account. When there is\n * nothing to derive against — no account, an empty hash, or one `checkPassword` finds\n * unverifiable — the password is checked against `dummyHash` instead and the result is false, so\n * every call runs exactly one scrypt derivation and how long a failure takes does not tell a\n * missing username from a wrong password. `check` is a parameter so a test can count the\n * derivations.\n */\nexport async function verifyAccountPassword(\n password: string,\n stored: string | null,\n dummyHash: () => Promise<string>,\n check: (password: string, stored: string) => Promise<PasswordCheck> = checkPassword,\n): Promise<boolean> {\n const outcome = stored === null ? \"unverifiable\" : await check(password, stored);\n if (outcome !== \"unverifiable\") return outcome === \"match\";\n await check(password, await dummyHash());\n return false;\n}\n\n/** Hashes the passwords this server writes; a test stands in a cheap one. */\n@Interface()\nexport abstract class PasswordHasher {\n abstract hash(password: string): Promise<string>;\n}\n\n/** scrypt at full strength. */\n@Component()\nexport class ScryptHasher implements PasswordHasher {\n hash(password: string): Promise<string> {\n return hashPassword(password, SCRYPT_COST);\n }\n}\n","/**\n * Auth service: admin seeding / login / logout / password change / session validation.\n *\n * No open registration: an empty users table seeds `admin` with a random password hashed and\n * discarded unseen, claimed through the first-login link; every other user is created by an\n * admin. Sessions are rows in auth_sessions (cookie holds the token, the row its sha256), so\n * they outlive a restart and renew in place.\n */\nimport { randomBytes } from \"node:crypto\";\nimport { tokensEqual } from \"./api-token.js\";\nimport type { UserInfo } from \"../api/types.js\";\nimport { HttpError } from \"../http/errors.js\";\nimport type { UserRow } from \"../db/repos/users.js\";\nimport { sessionTokenHash } from \"../db/repos/auth-sessions.js\";\nimport type { SessionViaValue, AuthSessionsRepo } from \"../db/repos/auth-sessions.js\";\nimport type { AuthRuntimeState } from \"./runtime-state.js\";\nimport { verifyAccountPassword, verifyPassword } from \"./password.js\";\nimport type { PasswordHasher } from \"./password.js\";\nimport { Component, Use, Interface } from \"@prismshadow/penguin-core/kernel\";\nimport type { AuthState, Clock, Config } from \"../hmr/capabilities.js\";\nimport type { Auth, AuthSessions, Users } from \"../mechanisms/identity.js\";\n\nexport const MIN_PASSWORD_LENGTH = 8;\nexport const REMOTE_SESSION_TTL_MS = 60 * 60_000;\n\n/** Built-in admin user_id. */\nexport const ADMIN_USER_ID = \"admin\";\n\n/**\n * Login throttling, per userId. After LOGIN_FREE_ATTEMPTS failures each attempt waits an\n * exponentially growing delay (1s doubling to a 60s cap), settling at a guess per minute.\n * Kept for nonexistent userIds too, so it is not an account-existence oracle — and neither is\n * the time a failure takes: a nonexistent userId is checked against a dummy hash (loginDummyHash).\n * Deliberately NOT covered: a concurrent burst before the first failure lands, and guesses\n * spread across many accounts.\n */\nconst LOGIN_FREE_ATTEMPTS = 5;\nconst LOGIN_BACKOFF_START_MS = 1000;\nconst LOGIN_BACKOFF_CAP_MS = 60_000;\n/** Failure entries idle longer than this are swept (bounds the map; far above the cap). */\nconst LOGIN_FAILURE_IDLE_MS = 15 * 60_000;\n\n/** Characters of base64url in a seeded password — 18 random bytes, 144 bits. */\nconst SEED_PASSWORD_CHARS = 24;\n\n/**\n * Never read or typed — hashed at once and discarded — which is what lets it be long enough\n * that the login endpoint, reachable the moment the account exists, has no search space.\n */\nexport function generateInitialAdminPassword(): string {\n return randomBytes(SEED_PASSWORD_CHARS).toString(\"base64url\").slice(0, SEED_PASSWORD_CHARS);\n}\n\n/**\n * \"desktop\" and \"setup\" may set a password without the old one (theirs is random and was never\n * shown); desktop-only routes open to \"desktop\" alone. \"token\" is the local API token's\n * authority, whether presented as a Bearer header or exchanged for a Remote cookie.\n * Exchanging the credential must not grant the person's privileges. Anything else reads as\n * \"password\".\n */\nexport type SessionVia = \"password\" | \"desktop\" | \"setup\" | \"token\";\n\n/** Row -> DTO. The two profile columns are omitted rather than sent as null when unset. */\nexport function toUserInfo(row: UserRow): UserInfo {\n return {\n userId: row.userId,\n isAdmin: row.isAdmin,\n passwordIsInitial: row.passwordIsInitial,\n ...(row.displayName !== null ? { displayName: row.displayName } : {}),\n ...(row.avatar !== null ? { avatar: row.avatar } : {}),\n createdAt: row.createdAt,\n };\n}\n\n@Component()\nexport class AuthService implements Auth {\n @Use() private readonly users!: Users;\n @Use() private readonly authSessions!: AuthSessions;\n /**\n * Process-scoped auth values (runtime-state.ts). Held by the RUNTIME so the link a boot\n * printed survives a platform push; everything else about auth is rebuilt with the App.\n */\n @Use() private readonly state!: AuthState;\n /** Provisions the initial Project at signup — declared at the consumer, below. */\n @Use() private readonly provisioner!: InitialProjectProvisioner;\n @Use() private readonly config!: Config;\n @Use() private readonly clock!: Clock;\n @Use() private readonly hasher!: PasswordHasher;\n /** Session lifetime, for the cookie that must expire WITH the session, not before it. */\n get sessionTtlMs(): number {\n return this.config.authSessionTtlMs;\n }\n private get sessionRenewMs(): number {\n return this.config.authSessionRenewMs;\n }\n /** Fixed initial password for the seeded admin; null generates a random one at seed time. */\n private get seedAdminPassword(): string | null {\n return this.config.seedAdminPassword;\n }\n\n setup(): void {\n this.authSessions.deleteExpired(this.clock.now().toISOString());\n }\n\n /**\n * Null once the server is claimed, so the only setup session that exists is a printed one.\n * On demand rather than in the constructor: seedAdmin() runs after it, so \"claimed?\" has no\n * answer there. A cached link whose row is gone is re-minted, not handed out dead.\n */\n mintFirstLogin(): string | null {\n if (!this.adminPasswordIsInitial()) return null;\n if (\n this.state.firstLoginToken !== null &&\n this.authenticateWithMeta(this.state.firstLoginToken) === null\n ) {\n this.state.firstLoginToken = null;\n }\n this.state.firstLoginToken ??= this.issueSession(ADMIN_USER_ID, \"setup\");\n return this.state.firstLoginToken;\n }\n\n /**\n * For the startup notice's pinned-seed gate (index.ts): a pin usually means the operator\n * knows the password, but an offline reset makes it stale, and the gate must see through\n * that or the rescue link stays suppressed.\n */\n async adminPasswordIs(password: string): Promise<boolean> {\n const row = this.users.findById(ADMIN_USER_ID);\n return row !== null && (await verifyPassword(password, row.passwordHash));\n }\n\n /**\n * Startup seeding (idempotent): creates the built-in admin and adopts default_project when\n * the users table is empty; if the initial Project fails, the user row is rolled back and\n * the server retries on next startup.\n */\n async seedAdmin(): Promise<void> {\n if (this.users.count() > 0) return;\n const password = this.seedAdminPassword ?? generateInitialAdminPassword();\n // A pinned override must meet the same policy as every other password — rejected before\n // any insert, so a configuration typo cannot create a trivially weak privileged account.\n if (password.length < MIN_PASSWORD_LENGTH) {\n throw new Error(\n `PENGUIN_SEED_ADMIN_PASSWORD must be at least ${MIN_PASSWORD_LENGTH} characters.`,\n );\n }\n const user: UserRow = {\n userId: ADMIN_USER_ID,\n passwordHash: await this.hasher.hash(password),\n isAdmin: true,\n passwordIsInitial: true,\n displayName: null,\n avatar: null,\n createdAt: this.clock.now().toISOString(),\n };\n this.users.insert(user);\n try {\n await this.provisioner.provisionInitialProject(user, true);\n } catch (err) {\n this.users.delete(user.userId);\n throw err;\n }\n }\n\n /**\n * Compared against the printed value, not merely verified: a cookie made out of ANY valid\n * token would let one person hand another a link into the sender's account. Not single-use,\n * because a prefetching browser would burn it before its reader clicked.\n */\n redeemFirstLogin(given: string): string | null {\n const expected = this.state.firstLoginToken;\n if (expected === null || given === \"\" || !tokensEqual(given, expected)) return null;\n return this.authenticateWithMeta(expected) === null ? null : expected;\n }\n\n /** Whether the built-in admin still runs on its initial password (gates the first-login link). */\n adminPasswordIsInitial(): boolean {\n return this.users.findById(ADMIN_USER_ID)?.passwordIsInitial === true;\n }\n\n /** Consecutive login failures per userId (see the throttling comment on the constants). */\n private readonly loginFailures = new Map<string, { failures: number; lastFailureAt: number }>();\n\n /** Cache for loginDummyHash. */\n private dummyHash: string | null = null;\n\n /**\n * The hash a sign-in with no account to check is verified against (verifyAccountPassword), so\n * it costs what a wrong password costs. Made by this server's own hasher, hence at the cost its\n * real hashes carry, from a random password nobody holds; computed on the first such sign-in\n * and kept.\n */\n private async loginDummyHash(): Promise<string> {\n this.dummyHash ??= await this.hasher.hash(randomBytes(18).toString(\"base64url\"));\n return this.dummyHash;\n }\n\n /** The wait imposed after `failures` consecutive failures (0 while within the free attempts). */\n private loginDelayMs(failures: number): number {\n const excess = failures - LOGIN_FREE_ATTEMPTS;\n if (excess <= 0) return 0;\n return Math.min(LOGIN_BACKOFF_START_MS * 2 ** (excess - 1), LOGIN_BACKOFF_CAP_MS);\n }\n\n async login(userId: string, password: string): Promise<{ user: UserInfo; token: string }> {\n const nowMs = this.clock.now().getTime();\n for (const [key, entry] of this.loginFailures) {\n if (nowMs - entry.lastFailureAt > LOGIN_FAILURE_IDLE_MS) this.loginFailures.delete(key);\n }\n const failed = this.loginFailures.get(userId);\n if (failed) {\n const readyAt = failed.lastFailureAt + this.loginDelayMs(failed.failures);\n if (nowMs < readyAt) {\n throw new HttpError(\n 429,\n \"too_many_attempts\",\n `Too many failed sign-in attempts. Try again in ${Math.ceil((readyAt - nowMs) / 1000)}s.`,\n );\n }\n }\n const row = this.users.findById(userId);\n // A missing account, or one whose hash cannot be checked, still costs one scrypt\n // derivation, and fails with the same 401 as a wrong password.\n const ok = await verifyAccountPassword(password, row?.passwordHash ?? null, () =>\n this.loginDummyHash(),\n );\n if (!row || !ok) {\n this.loginFailures.set(userId, {\n failures: (failed?.failures ?? 0) + 1,\n lastFailureAt: this.clock.now().getTime(),\n });\n throw new HttpError(401, \"invalid_credentials\", \"Incorrect username or password.\");\n }\n this.loginFailures.delete(userId);\n this.authSessions.deleteExpired(this.clock.now().toISOString());\n return { user: toUserInfo(row), token: this.issueSession(row.userId, \"password\") };\n }\n\n /**\n * Whether a user is an admin, by id — for a caller that has a user's NAME rather than an\n * authenticated request: the terminal relay reads the owner out of a pty reference the\n * runtime has already matched against the signed-in user (machines/terminal-relay.ts), and\n * what is left to decide is whether that user may reach machines at all. Unknown users are\n * not admins.\n */\n isAdmin(userId: string): boolean {\n return this.users.findById(userId)?.isAdmin === true;\n }\n\n /**\n * Desktop-mode sign-in: an admin session with no password check — the claim route already\n * redeemed the shell's one-shot token, which is the credential here. Throws only on a\n * broken deployment (seeding runs before the route exists).\n */\n loginDesktop(): { user: UserInfo; token: string } {\n const row = this.users.findById(ADMIN_USER_ID);\n if (!row) {\n throw new HttpError(500, \"internal\", \"Built-in admin has not been seeded.\");\n }\n return { user: toUserInfo(row), token: this.issueSession(row.userId, \"desktop\") };\n }\n\n issueRemoteSession(): { token: string; expiresAt: string; maxAgeSeconds: number } {\n const row = this.users.findById(ADMIN_USER_ID);\n if (!row) {\n throw new HttpError(500, \"internal\", \"Built-in admin has not been seeded.\");\n }\n const maxAgeSeconds = Math.floor(REMOTE_SESSION_TTL_MS / 1000);\n return {\n ...this.authSessions.issue({\n userId: row.userId,\n via: \"remote\",\n now: this.clock.now(),\n ttlMs: REMOTE_SESSION_TTL_MS,\n }),\n maxAgeSeconds,\n };\n }\n\n /** Self password change (user settings): validates the old password first; the current session stays valid. */\n async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {\n const row = this.users.findById(userId);\n if (!row || !(await verifyPassword(oldPassword, row.passwordHash))) {\n throw new HttpError(400, \"password_mismatch\", \"Current password is incorrect.\");\n }\n await this.setPassword(userId, newPassword);\n }\n\n /** No old-password check; the me route gates which sessions may call this. */\n async setInitialPassword(userId: string, newPassword: string): Promise<void> {\n await this.setPassword(userId, newPassword);\n }\n\n /**\n * Validation comes FIRST, so a rejected password leaves the first-login link alive: burning\n * it on a typo would strand the claimer until a restart.\n */\n private async setPassword(userId: string, newPassword: string): Promise<void> {\n if (newPassword.length < MIN_PASSWORD_LENGTH) {\n throw new HttpError(400, \"invalid_password\", \"Password must be at least 8 characters.\");\n }\n this.users.updatePassword(userId, await this.hasher.hash(newPassword), false);\n // A successful set proves at least what the successful login that resets the counter\n // proves (the old password, or a desktop/setup session), so it resets it too. Without\n // this, anyone spamming the login endpoint holds the backoff window open, and the sign-in\n // that follows a first-login claim (routes/me.ts) would 429 AFTER the password committed —\n // reporting failure for a change that took, with the claimer's setup session already gone.\n this.loginFailures.delete(userId);\n // EVERY first-login session for this account, not just the link this process printed: an\n // earlier boot's link can still be live in a terminal scrollback, and a `setup` session is\n // allowed to change the password without knowing the old one — so one left behind is an\n // account takeover waiting to happen.\n if (userId === ADMIN_USER_ID) {\n this.authSessions.deleteByUserAndVia(userId, \"setup\");\n this.state.firstLoginToken = null;\n }\n }\n\n /** Ends a session: the row is the session, so logout deletes it. Unknown token is a no-op. */\n logout(token: string): void {\n this.authSessions.delete(sessionTokenHash(token));\n }\n\n /**\n * The current boot's local API token (what control-env injection hands to tool\n * subprocesses); null when none was minted. It lives on the runtime state, not on this\n * service: the token was written to `<root>/api-token` by the process, so it must\n * outlive the App a push replaces (auth/runtime-state.ts).\n */\n localApiToken(): string | null {\n return this.state.apiToken;\n }\n\n /**\n * Validates a Bearer token against the boot's local API token (constant-time compare):\n * a match authenticates as the built-in admin — holding the token proves filesystem\n * access to the data root, which is admin authority (see auth/api-token.ts). Returns\n * null on mismatch, when no token was minted, or when the admin row is missing.\n */\n authenticateApiToken(token: string): { user: UserRow; via: SessionVia } | null {\n const apiToken = this.state.apiToken;\n if (apiToken === null || token.length === 0) return null;\n if (!tokensEqual(token, apiToken)) return null;\n const user = this.users.findById(ADMIN_USER_ID);\n return user === null ? null : { user, via: \"token\" };\n }\n\n /**\n * Validates the cookie token. Sliding renewal tops the expiry up IN PLACE, so the cookie\n * value never changes and there is no second identity to revoke. Only a session whose own\n * span reaches the renewal window slides — a one-hour minted token must expire at its hour,\n * not stretch by being used.\n */\n authenticateWithMeta(token: string): { user: UserRow; via: SessionVia; renewed: boolean } | null {\n const tokenHash = sessionTokenHash(token);\n const session = this.authSessions.findByTokenHash(tokenHash);\n if (!session) return null;\n const now = this.clock.now();\n const expiresAt = Date.parse(session.expiresAt);\n if (expiresAt <= now.getTime()) {\n this.authSessions.delete(tokenHash);\n return null;\n }\n const user = this.users.findById(session.userId);\n if (!user) return null;\n const renewable = expiresAt - Date.parse(session.createdAt) >= this.sessionRenewMs;\n let renewed = false;\n if (renewable && expiresAt - now.getTime() < this.sessionRenewMs) {\n this.authSessions.touch(tokenHash, new Date(now.getTime() + this.sessionTtlMs).toISOString());\n renewed = true;\n }\n const via: SessionVia =\n session.via === \"remote\"\n ? \"token\"\n : session.via === \"desktop\"\n ? \"desktop\"\n : session.via === \"setup\"\n ? \"setup\"\n : \"password\";\n return { user, via, renewed };\n }\n\n /**\n * Whether the session behind a cookie token still exists and has not expired — the same\n * two facts {@link authenticateWithMeta} establishes, without its sliding renewal or its\n * user lookup. A connection that outlives the request that opened it has to re-ask\n * periodically (the SSE heartbeat does, once per beat), and asking through the renewing\n * path would top the expiry up every time: a window nobody has touched since last week\n * would stay signed in purely because it is still connected.\n */\n sessionIsLive(token: string): boolean {\n const session = this.authSessions.findByTokenHash(sessionTokenHash(token));\n if (!session) return false;\n return Date.parse(session.expiresAt) > this.clock.now().getTime();\n }\n\n private issueSession(userId: string, via: SessionViaValue): string {\n return this.authSessions.issue({\n userId,\n via,\n now: this.clock.now(),\n ttlMs: this.sessionTtlMs,\n }).token;\n }\n}\n\n/** What signing a new user in needs of project administration — declared at the consumer. */\n@Interface()\nexport abstract class InitialProjectProvisioner {\n abstract provisionInitialProject(user: UserRow, isAdmin: boolean): Promise<void>;\n}\n","/**\n * Local API token (`<root>/api-token`): the machine-local credential behind\n * `Authorization: Bearer`.\n *\n * Minted fresh at every server boot (same recipe as auth-session tokens) and written to\n * the data root with owner-only permissions, so anything that can read the file can call\n * the API as the admin. That equivalence is the authorization model, not an accident:\n * local filesystem access to the data root already IS admin authority — the same rule\n * `penguin server reset-admin-password` stands on (whoever can run it owns web.db\n * anyway) — and agents driving their own harness through the CLI is the product feature\n * this token exists for (server-driven Sessions hand it to tool subprocesses as\n * PENGUIN_API_TOKEN). Rotation per boot bounds the life of any leaked copy to the\n * process that minted it.\n */\nimport { createHash, randomBytes, timingSafeEqual } from \"node:crypto\";\nimport fs from \"node:fs\";\nimport path from \"node:path\";\n\nexport function apiTokenPath(root: string): string {\n return path.join(root, \"api-token\");\n}\n\n/** Mints a boot-scoped local API token (the auth-session token recipe). */\nexport function mintApiToken(): string {\n return randomBytes(32).toString(\"base64url\");\n}\n\n/**\n * Persists the boot token (owner-only file, tmp + rename so a concurrent reader never\n * sees a partial write). Best-effort like the initial-password file: an exotic read-only\n * root must not stop the server — local CLI callers then fall back to\n * PENGUIN_API_TOKEN.\n */\nexport function storeApiToken(root: string, token: string): void {\n try {\n fs.mkdirSync(root, { recursive: true });\n const target = apiTokenPath(root);\n const tmp = `${target}.${process.pid}.tmp`;\n fs.writeFileSync(tmp, `${token}\\n`, { mode: 0o600 });\n fs.renameSync(tmp, target);\n } catch {\n // Best-effort: Bearer auth still works for callers holding the token some other way.\n }\n}\n\n/** The stored token, or null when absent/unreadable/empty (shared with the CLI's file fallback). */\nexport function readApiToken(root: string): string | null {\n try {\n const value = fs.readFileSync(apiTokenPath(root), \"utf8\").trim();\n return value === \"\" ? null : value;\n } catch {\n return null;\n }\n}\n\n/**\n * Constant-time equality for token checks: both sides are reduced to fixed-size sha256\n * digests first, so the comparison's timing depends on neither the content nor the\n * length of an attacker-supplied value.\n */\nexport function tokensEqual(a: string, b: string): boolean {\n const digest = (v: string): Buffer => createHash(\"sha256\").update(v).digest();\n return timingSafeEqual(digest(a), digest(b));\n}\n","/**\n * Unified HTTP error: `{error: {code, message, details?}}` response body,\n * with message in English.\n *\n * Routes and services express business errors via throw HttpError; app-level onError\n * uniformly converges these into a JSON response, with unknown errors converged to 500\n * (never leaking internal details to the client).\n *\n * `details` carries the facts a client acts on beside the code — the id a 409 is about, say — so\n * that no client has to parse them out of the message, which is prose and may change.\n */\nimport type { Context } from \"hono\";\nimport type { ErrorBody, ErrorDetails } from \"../api/types.js\";\n\nexport class HttpError extends Error {\n constructor(\n readonly status: number,\n readonly code: string,\n message: string,\n readonly retryAfterSeconds?: number,\n readonly details?: ErrorDetails,\n ) {\n super(message);\n this.name = \"HttpError\";\n }\n}\n\n/** A 400 for a request the route cannot take as it is; validate.ts re-exports it beside its checks. */\nexport function badRequest(message: string): HttpError {\n return new HttpError(400, \"bad_request\", message);\n}\n\nexport function errorBody(code: string, message: string, details?: ErrorDetails): ErrorBody {\n return { error: { code, message, ...(details !== undefined ? { details } : {}) } };\n}\n\n/**\n * Model missing a credential: the provider SDK throws this at **client-construction\n * time** (hit by both creating a Session and resuming a Session); the original message\n * is full of environment variable names, meaningless to a user — uniformly replaced\n * with a single actionable sentence. The frontend produces localized text by code\n * (message is only a fallback); see web's lib/api-error.ts.\n */\nexport function isMissingCredential(err: unknown): boolean {\n const message = err instanceof Error ? err.message : String(err);\n return /missing credentials|api[_ ]?key/i.test(message);\n}\n\nexport function modelCredentialMissing(modelId: string): HttpError {\n return new HttpError(\n 400,\n \"model_credential_missing\",\n `Model ${modelId} has no API key yet. Configure it on the Models page first.`,\n );\n}\n\n/** app.onError handler: maps HttpError through as-is; everything else is logged and converged to 500. */\nexport function handleError(err: Error, c: Context): Response {\n if (err instanceof HttpError) {\n if (err.retryAfterSeconds !== undefined) {\n c.header(\"Retry-After\", String(err.retryAfterSeconds));\n }\n return c.json(errorBody(err.code, err.message, err.details), err.status as 400);\n }\n // Unknown error: print the stack for diagnosis, but never expose details externally.\n console.error(`[server] Unhandled exception: ${err.stack ?? err.message}`);\n return c.json(errorBody(\"internal\", \"Internal server error.\"), 500);\n}\n"],"mappings":"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","names":["_init","randomBytes","randomBytes","timingSafeEqual","timingSafeEqual","Component","Interface","randomBytes","_init","Component","Interface"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/repos/users.ts","../src/db/repos/auth-sessions.ts"],"sourcesContent":["/**\n * users table repo: pure SQL wrapper, no business rules.\n * user_id is the login name (a semantic id, specified at creation, immutable).\n */\nimport { Component, Use } from \"@prismshadow/penguin-core/kernel\";\nimport type { Db } from \"../../hmr/capabilities.js\";\nimport type { Users } from \"../../mechanisms/identity.js\";\n\nexport interface UserRow {\n userId: string;\n passwordHash: string;\n isAdmin: boolean;\n /** Still using the initial password (seeded / set by an admin); cleared to 0 once the user changes it. */\n passwordIsInitial: boolean;\n /** Nickname shown in place of the id; null = never set. */\n displayName: string | null;\n /** Avatar as a data URL; null = never set. */\n avatar: string | null;\n createdAt: string;\n}\n\nfunction mapRow(r: Record<string, unknown>): UserRow {\n return {\n userId: r.user_id as string,\n passwordHash: r.password_hash as string,\n isAdmin: (r.is_admin as number) === 1,\n passwordIsInitial: (r.password_is_initial as number) === 1,\n displayName: (r.display_name as string | null) ?? null,\n avatar: (r.avatar as string | null) ?? null,\n createdAt: r.created_at as string,\n };\n}\n\n@Component()\nexport class UsersRepo implements Users {\n @Use() private readonly db!: Db;\n\n insert(row: UserRow): void {\n this.db\n .prepare(\n \"INSERT INTO users (user_id, password_hash, is_admin, password_is_initial, display_name, avatar, created_at)\" +\n \" VALUES (?, ?, ?, ?, ?, ?, ?)\",\n )\n .run(\n row.userId,\n row.passwordHash,\n row.isAdmin ? 1 : 0,\n row.passwordIsInitial ? 1 : 0,\n row.displayName,\n row.avatar,\n row.createdAt,\n );\n }\n\n findById(userId: string): UserRow | null {\n const r = this.db.prepare(\"SELECT * FROM users WHERE user_id = ?\").get(userId);\n return r ? mapRow(r) : null;\n }\n\n /** All users (for the admin user backend), ordered by creation time ascending. */\n list(): UserRow[] {\n const rows = this.db.prepare(\"SELECT * FROM users ORDER BY created_at ASC, user_id ASC\").all();\n return rows.map(mapRow);\n }\n\n count(): number {\n const r = this.db.prepare(\"SELECT COUNT(*) AS n FROM users\").get();\n return (r?.n as number) ?? 0;\n }\n\n /** Update the password hash; isInitial marks whether the password was set by someone else (seed / admin). */\n updatePassword(userId: string, passwordHash: string, isInitial: boolean): void {\n this.db\n .prepare(\"UPDATE users SET password_hash = ?, password_is_initial = ? WHERE user_id = ?\")\n .run(passwordHash, isInitial ? 1 : 0, userId);\n }\n\n /**\n * Writes the profile fields the patch names, leaving the rest as they are: an absent key\n * keeps the stored value, `null` clears it, a string replaces it. A patch naming neither\n * field writes nothing rather than issuing an UPDATE with an empty SET list.\n */\n updateProfile(\n userId: string,\n patch: { displayName?: string | null; avatar?: string | null },\n ): void {\n const sets: string[] = [];\n const values: (string | null)[] = [];\n if (patch.displayName !== undefined) {\n sets.push(\"display_name = ?\");\n values.push(patch.displayName);\n }\n if (patch.avatar !== undefined) {\n sets.push(\"avatar = ?\");\n values.push(patch.avatar);\n }\n if (sets.length === 0) return;\n this.db.prepare(`UPDATE users SET ${sets.join(\", \")} WHERE user_id = ?`).run(...values, userId);\n }\n\n /** Used by admin user deletion and account-creation compensation paths (owned Projects must be cleaned up first). */\n delete(userId: string): void {\n this.db.prepare(\"DELETE FROM users WHERE user_id = ?\").run(userId);\n }\n}\n","/**\n * auth_sessions table repo (server-side sessions backing the HttpOnly cookie). Stores only\n * sha256(token); the raw token appears only in the cookie. `issue()` is the ONE minting point,\n * so a CLI-minted row cannot drift from one the server issues itself.\n */\nimport { createHash, randomBytes } from \"node:crypto\";\nimport { Component, Use } from \"@prismshadow/penguin-core/kernel\";\nimport type { Db } from \"../../hmr/capabilities.js\";\nimport type { AuthSessions } from \"../../mechanisms/identity.js\";\n\n/** How a session was established, as stored. NULL on rows formed before the column existed. */\nexport type SessionViaValue = \"password\" | \"desktop\" | \"setup\" | \"cli\" | \"remote\";\n\nexport interface AuthSessionRow {\n tokenHash: string;\n userId: string;\n createdAt: string;\n expiresAt: string;\n via: SessionViaValue | null;\n}\n\n/** sha256 hex of a raw session token — the only form the database ever holds. */\nexport function sessionTokenHash(token: string): string {\n return createHash(\"sha256\").update(token).digest(\"hex\");\n}\n\n@Component()\nexport class AuthSessionsRepo implements AuthSessions {\n @Use() private readonly db!: Db;\n\n /**\n * Mints a session and returns the raw token for a cookie or the terminal. `maxTtlMs` caps a\n * caller-supplied lifetime (`--ttl-seconds`): one longer than an ordinary session would make\n * a leaked token file outlive every other credential, sliding forever. Expired rows are\n * swept here too, so a cron that only mints does not accumulate them.\n */\n issue(opts: {\n userId: string;\n via: SessionViaValue;\n now: Date;\n ttlMs: number;\n maxTtlMs?: number;\n }): { token: string; expiresAt: string } {\n const ttlMs = Math.max(1_000, Math.min(opts.ttlMs, opts.maxTtlMs ?? opts.ttlMs));\n const token = randomBytes(32).toString(\"base64url\");\n const createdAt = opts.now.toISOString();\n const expiresAt = new Date(opts.now.getTime() + ttlMs).toISOString();\n this.deleteExpired(createdAt);\n this.insert({\n tokenHash: sessionTokenHash(token),\n userId: opts.userId,\n createdAt,\n expiresAt,\n via: opts.via,\n });\n return { token, expiresAt };\n }\n\n insert(row: AuthSessionRow): void {\n this.db\n .prepare(\n \"INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at, via) VALUES (?, ?, ?, ?, ?)\",\n )\n .run(row.tokenHash, row.userId, row.createdAt, row.expiresAt, row.via);\n }\n\n findByTokenHash(tokenHash: string): AuthSessionRow | null {\n const r = this.db.prepare(\"SELECT * FROM auth_sessions WHERE token_hash = ?\").get(tokenHash);\n if (!r) return null;\n return {\n tokenHash: r.token_hash as string,\n userId: r.user_id as string,\n createdAt: r.created_at as string,\n expiresAt: r.expires_at as string,\n via: (r.via as SessionViaValue | null) ?? null,\n };\n }\n\n /** Sliding renewal: update the expiration time. */\n touch(tokenHash: string, expiresAt: string): void {\n this.db\n .prepare(\"UPDATE auth_sessions SET expires_at = ? WHERE token_hash = ?\")\n .run(expiresAt, tokenHash);\n }\n\n delete(tokenHash: string): void {\n this.db.prepare(\"DELETE FROM auth_sessions WHERE token_hash = ?\").run(tokenHash);\n }\n\n /** Opportunistically clean up expired sessions (called during login/validation). */\n deleteExpired(nowIso: string): void {\n this.db.prepare(\"DELETE FROM auth_sessions WHERE expires_at < ?\").run(nowIso);\n }\n\n /** Clear all sessions for a user (forces re-login after an admin resets the password). */\n deleteByUser(userId: string): void {\n this.db.prepare(\"DELETE FROM auth_sessions WHERE user_id = ?\").run(userId);\n }\n\n /**\n * Clear a user's sessions of one kind. Used to end EVERY first-login session when a password\n * is set: an earlier boot's link may still be live in someone's scrollback, and a `setup`\n * session may change the password without knowing the old one.\n */\n deleteByUserAndVia(userId: string, via: SessionViaValue): void {\n this.db.prepare(\"DELETE FROM auth_sessions WHERE user_id = ? AND via = ?\").run(userId, via);\n }\n}\n"],"mappings":"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","names":["Component","Use","_db_dec","_init","Component","Use"]}
|