@fonlan/dsh-web-auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/cordis.patch.yml +23 -0
- package/lib/auth-core.d.ts +78 -0
- package/lib/auth-core.js +175 -0
- package/lib/client/index.d.ts +9 -0
- package/lib/client.js +305 -0
- package/lib/context-types.d.ts +37 -0
- package/lib/context-types.js +1 -0
- package/lib/gate.d.ts +32 -0
- package/lib/gate.js +55 -0
- package/lib/handlers.d.ts +121 -0
- package/lib/handlers.js +372 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.js +165 -0
- package/lib/login-page.d.ts +22 -0
- package/lib/login-page.js +80 -0
- package/lib/profile-patch.d.ts +45 -0
- package/lib/profile-patch.js +154 -0
- package/lib/state.d.ts +12 -0
- package/lib/state.js +78 -0
- package/package.json +70 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** The two bind hosts the webserver config schema accepts. */
|
|
2
|
+
export declare const ALLOWED_LISTEN_HOSTS: readonly ["127.0.0.1", "0.0.0.0"];
|
|
3
|
+
export type ListenHost = (typeof ALLOWED_LISTEN_HOSTS)[number];
|
|
4
|
+
/**
|
|
5
|
+
* Delay between confirming the change to the browser and writing the patch
|
|
6
|
+
* file. The HMR watcher fires within milliseconds of the write and the
|
|
7
|
+
* webserver teardown destroys every connection — including the one carrying
|
|
8
|
+
* the confirmation — so the write lands a beat AFTER the response flushed.
|
|
9
|
+
*/
|
|
10
|
+
export declare const LISTEN_APPLY_DELAY_MS = 300;
|
|
11
|
+
/** Whether a value is one of the two allowed bind-host literals. */
|
|
12
|
+
export declare function isAllowedListenHost(value: unknown): value is ListenHost;
|
|
13
|
+
/** Whether a patch document already spells the webserver row. */
|
|
14
|
+
export declare function patchHasWebserverEntry(content: string): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Pure text transform: set the `host` of the `webserver` patch entry.
|
|
17
|
+
*
|
|
18
|
+
* - entry present with a `host:` under `config:` → the value is replaced;
|
|
19
|
+
* - entry present without `host:` → the line is inserted under `config:`;
|
|
20
|
+
* - entry present without `config:` → a config block is appended;
|
|
21
|
+
* - no entry at all → a complete row is appended (name/inject/port restated).
|
|
22
|
+
*
|
|
23
|
+
* @param content - the patch file's current text.
|
|
24
|
+
* @param host - the target bind host literal.
|
|
25
|
+
* @returns the next file text; every other byte is untouched.
|
|
26
|
+
*/
|
|
27
|
+
export declare function setWebserverHost(content: string, host: ListenHost): string;
|
|
28
|
+
/** Read a patch file as text; a missing file reads as the empty document. */
|
|
29
|
+
export declare function readPatchOrEmpty(patchPath: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Which patch layer owns the webserver override: the highest layer that
|
|
32
|
+
* spells the row (home outranks the profile, the same order the boot
|
|
33
|
+
* applies them); when no layer spells it, the profile patch is where the
|
|
34
|
+
* row will be created.
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveListenPatchPath(profilePatch: string, homePatch: string): string;
|
|
37
|
+
/**
|
|
38
|
+
* Read + transform the owning patch layer for `host`, returning the next
|
|
39
|
+
* file content. Throws when the file cannot be read.
|
|
40
|
+
*/
|
|
41
|
+
export declare function prepareWebserverHostPatch(patchPath: string, host: ListenHost): string;
|
|
42
|
+
/** Atomic write of prepared patch content (tmp + rename, like the state files). */
|
|
43
|
+
export declare function writeWebserverHostPatch(patchPath: string, content: string): void;
|
|
44
|
+
/** Whether a path exists on disk (guards the home-patch resolution). */
|
|
45
|
+
export declare function patchExists(patchPath: string): boolean;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-web-auth — profile patch editing for the web server bind host.
|
|
3
|
+
*
|
|
4
|
+
* The webserver's `host` is composition config: the dsh-web-app bundle row
|
|
5
|
+
* defaults to `ctx.webStartup.host ?? '127.0.0.1'`, and a later patch layer
|
|
6
|
+
* (the profile's cordis.patch.yml, or the home-level patch) overrides it.
|
|
7
|
+
* The boot process tails those patch files with HMR, so rewriting the
|
|
8
|
+
* override entry in place hot-applies the change: the webserver row reloads,
|
|
9
|
+
* closes its listener, and rebinds on the new host — no process restart.
|
|
10
|
+
*
|
|
11
|
+
* This module only ever touches the `host` value inside the `webserver`
|
|
12
|
+
* entry. Everything else — comments, other entries, and especially the
|
|
13
|
+
* `!!js` port expression — is preserved byte-for-byte, which a YAML
|
|
14
|
+
* round-trip could never guarantee. A fresh entry (when no layer spells the
|
|
15
|
+
* webserver row) restates `name`/`inject` and the port expression,
|
|
16
|
+
* because a patch entry replaces the targeted row's whole `config`.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { randomBytes } from 'node:crypto';
|
|
20
|
+
/** The two bind hosts the webserver config schema accepts. */
|
|
21
|
+
export const ALLOWED_LISTEN_HOSTS = ['127.0.0.1', '0.0.0.0'];
|
|
22
|
+
/**
|
|
23
|
+
* Delay between confirming the change to the browser and writing the patch
|
|
24
|
+
* file. The HMR watcher fires within milliseconds of the write and the
|
|
25
|
+
* webserver teardown destroys every connection — including the one carrying
|
|
26
|
+
* the confirmation — so the write lands a beat AFTER the response flushed.
|
|
27
|
+
*/
|
|
28
|
+
export const LISTEN_APPLY_DELAY_MS = 300;
|
|
29
|
+
/** Whether a value is one of the two allowed bind-host literals. */
|
|
30
|
+
export function isAllowedListenHost(value) {
|
|
31
|
+
return value === '127.0.0.1' || value === '0.0.0.0';
|
|
32
|
+
}
|
|
33
|
+
/** The patch row id owning the web transport. */
|
|
34
|
+
const WEBSERVER_ROW_ID = 'webserver';
|
|
35
|
+
/** The bundle's port expression, restated when inserting a fresh row. */
|
|
36
|
+
const WEBSERVER_PORT_EXPRESSION = 'ctx.webStartup.port ?? 3080';
|
|
37
|
+
/** The webserver's package name, restated when inserting a fresh row. */
|
|
38
|
+
const WEBSERVER_PACKAGE = '@deepseek-ai/dsh-host-webserver';
|
|
39
|
+
const ENTRY_LINE = /^- id:\s*['"]?webserver['"]?\s*(?:#.*)?$/;
|
|
40
|
+
const LIST_ITEM = /^-\s/;
|
|
41
|
+
const CONFIG_LINE = /^(\s*)config:\s*(?:#.*)?$/;
|
|
42
|
+
const HOST_LINE = /^(\s*)host:\s*(.*)$/;
|
|
43
|
+
/** Whether a patch document already spells the webserver row. */
|
|
44
|
+
export function patchHasWebserverEntry(content) {
|
|
45
|
+
for (const line of content.split('\n'))
|
|
46
|
+
if (ENTRY_LINE.test(line))
|
|
47
|
+
return true;
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Pure text transform: set the `host` of the `webserver` patch entry.
|
|
52
|
+
*
|
|
53
|
+
* - entry present with a `host:` under `config:` → the value is replaced;
|
|
54
|
+
* - entry present without `host:` → the line is inserted under `config:`;
|
|
55
|
+
* - entry present without `config:` → a config block is appended;
|
|
56
|
+
* - no entry at all → a complete row is appended (name/inject/port restated).
|
|
57
|
+
*
|
|
58
|
+
* @param content - the patch file's current text.
|
|
59
|
+
* @param host - the target bind host literal.
|
|
60
|
+
* @returns the next file text; every other byte is untouched.
|
|
61
|
+
*/
|
|
62
|
+
export function setWebserverHost(content, host) {
|
|
63
|
+
const lines = content.split('\n');
|
|
64
|
+
let entryStart = -1;
|
|
65
|
+
for (let i = 0; i < lines.length; i++) {
|
|
66
|
+
if (ENTRY_LINE.test(lines[i])) {
|
|
67
|
+
entryStart = i;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (entryStart === -1) {
|
|
72
|
+
const block = [
|
|
73
|
+
'- id: ' + WEBSERVER_ROW_ID,
|
|
74
|
+
' name: ' + JSON.stringify(WEBSERVER_PACKAGE),
|
|
75
|
+
' inject: [webStartup]',
|
|
76
|
+
' config:',
|
|
77
|
+
' host: ' + host,
|
|
78
|
+
' port: !!js ' + WEBSERVER_PORT_EXPRESSION
|
|
79
|
+
].join('\n');
|
|
80
|
+
return content.length === 0 || content.endsWith('\n') ? content + block + '\n' : content + '\n' + block + '\n';
|
|
81
|
+
}
|
|
82
|
+
// The entry block ends at the next top-level list item or EOF.
|
|
83
|
+
let entryEnd = lines.length;
|
|
84
|
+
for (let i = entryStart + 1; i < lines.length; i++) {
|
|
85
|
+
if (LIST_ITEM.test(lines[i])) {
|
|
86
|
+
entryEnd = i;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let configIndex = -1;
|
|
91
|
+
let configIndent = 0;
|
|
92
|
+
for (let i = entryStart; i < entryEnd; i++) {
|
|
93
|
+
const match = CONFIG_LINE.exec(lines[i]);
|
|
94
|
+
if (match !== null) {
|
|
95
|
+
configIndex = i;
|
|
96
|
+
configIndent = match[1].length;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (configIndex === -1) {
|
|
101
|
+
// No config block: append one (2-space entry indent, as the template).
|
|
102
|
+
lines.splice(entryEnd, 0, ' config:', ' host: ' + host, ' port: !!js ' + WEBSERVER_PORT_EXPRESSION);
|
|
103
|
+
return lines.join('\n');
|
|
104
|
+
}
|
|
105
|
+
for (let i = configIndex + 1; i < entryEnd; i++) {
|
|
106
|
+
const match = HOST_LINE.exec(lines[i]);
|
|
107
|
+
if (match !== null && match[1].length > configIndent) {
|
|
108
|
+
lines[i] = match[1] + 'host: ' + host;
|
|
109
|
+
return lines.join('\n');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Config block present but no host key: insert right after `config:`.
|
|
113
|
+
lines.splice(configIndex + 1, 0, ' '.repeat(configIndent + 2) + 'host: ' + host);
|
|
114
|
+
return lines.join('\n');
|
|
115
|
+
}
|
|
116
|
+
/** Read a patch file as text; a missing file reads as the empty document. */
|
|
117
|
+
export function readPatchOrEmpty(patchPath) {
|
|
118
|
+
try {
|
|
119
|
+
return readFileSync(patchPath, 'utf8');
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (error.code === 'ENOENT')
|
|
123
|
+
return '';
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Which patch layer owns the webserver override: the highest layer that
|
|
129
|
+
* spells the row (home outranks the profile, the same order the boot
|
|
130
|
+
* applies them); when no layer spells it, the profile patch is where the
|
|
131
|
+
* row will be created.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveListenPatchPath(profilePatch, homePatch) {
|
|
134
|
+
if (patchHasWebserverEntry(readPatchOrEmpty(homePatch)))
|
|
135
|
+
return homePatch;
|
|
136
|
+
return profilePatch;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Read + transform the owning patch layer for `host`, returning the next
|
|
140
|
+
* file content. Throws when the file cannot be read.
|
|
141
|
+
*/
|
|
142
|
+
export function prepareWebserverHostPatch(patchPath, host) {
|
|
143
|
+
return setWebserverHost(readPatchOrEmpty(patchPath), host);
|
|
144
|
+
}
|
|
145
|
+
/** Atomic write of prepared patch content (tmp + rename, like the state files). */
|
|
146
|
+
export function writeWebserverHostPatch(patchPath, content) {
|
|
147
|
+
const tmp = patchPath + '.tmp-' + randomBytes(4).toString('hex');
|
|
148
|
+
writeFileSync(tmp, content);
|
|
149
|
+
renameSync(tmp, patchPath);
|
|
150
|
+
}
|
|
151
|
+
/** Whether a path exists on disk (guards the home-patch resolution). */
|
|
152
|
+
export function patchExists(patchPath) {
|
|
153
|
+
return existsSync(patchPath);
|
|
154
|
+
}
|
package/lib/state.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Resolve the harness home the same way dsh does: $DSH_HOME, else ~/.dsh. */
|
|
2
|
+
export declare function resolveDshHome(env?: NodeJS.ProcessEnv): string;
|
|
3
|
+
/** Directory holding web-auth state; created on first use (0700). */
|
|
4
|
+
export declare function stateDir(dshHome: string): string;
|
|
5
|
+
/** The stored password hash, or undefined when auth is not yet configured. */
|
|
6
|
+
export declare function readPasswordHash(dshHome: string): string | undefined;
|
|
7
|
+
/** Persist a new password hash (atomic replace, 0600). */
|
|
8
|
+
export declare function writePasswordHash(dshHome: string, hash: string): void;
|
|
9
|
+
/** The session-signing secret, minted and persisted on first use. */
|
|
10
|
+
export declare function readSecret(dshHome: string): Buffer;
|
|
11
|
+
/** Replace the signing secret (password change): every session dies. */
|
|
12
|
+
export declare function rotateSecret(dshHome: string): Buffer;
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-web-auth — persistent state under the harness home.
|
|
3
|
+
*
|
|
4
|
+
* All state lives under `$DSH_HOME/web-auth/` (0600 files, 0700 dir):
|
|
5
|
+
* password.hash — one scrypt line (GUI-maintained; absent = auth disabled)
|
|
6
|
+
* secret — 32 random bytes signing session cookies (rotated on
|
|
7
|
+
* password change, which logs every session out)
|
|
8
|
+
*/
|
|
9
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { randomBytes } from 'node:crypto';
|
|
13
|
+
/** Resolve the harness home the same way dsh does: $DSH_HOME, else ~/.dsh. */
|
|
14
|
+
export function resolveDshHome(env = process.env) {
|
|
15
|
+
return env.DSH_HOME?.trim() || join(homedir(), '.dsh');
|
|
16
|
+
}
|
|
17
|
+
/** Directory holding web-auth state; created on first use (0700). */
|
|
18
|
+
export function stateDir(dshHome) {
|
|
19
|
+
const dir = join(dshHome, 'web-auth');
|
|
20
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
/** The stored password hash, or undefined when auth is not yet configured. */
|
|
24
|
+
export function readPasswordHash(dshHome) {
|
|
25
|
+
const file = join(stateDir(dshHome), 'password.hash');
|
|
26
|
+
if (!existsSync(file))
|
|
27
|
+
return undefined;
|
|
28
|
+
const line = readFileSync(file, 'utf8').trim();
|
|
29
|
+
return line.length === 0 ? undefined : line;
|
|
30
|
+
}
|
|
31
|
+
/** Persist a new password hash (atomic replace, 0600). */
|
|
32
|
+
export function writePasswordHash(dshHome, hash) {
|
|
33
|
+
const file = join(stateDir(dshHome), 'password.hash');
|
|
34
|
+
const tmp = file + '.tmp-' + randomBytes(4).toString('hex');
|
|
35
|
+
writeFileSync(tmp, hash + '\n', { mode: 0o600 });
|
|
36
|
+
renameSync(tmp, file);
|
|
37
|
+
try {
|
|
38
|
+
chmodSync(file, 0o600);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* best effort on filesystems without chmod */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** The session-signing secret, minted and persisted on first use. */
|
|
45
|
+
export function readSecret(dshHome) {
|
|
46
|
+
const file = join(stateDir(dshHome), 'secret');
|
|
47
|
+
if (existsSync(file)) {
|
|
48
|
+
const raw = readFileSync(file);
|
|
49
|
+
if (raw.length >= 32)
|
|
50
|
+
return raw;
|
|
51
|
+
}
|
|
52
|
+
const secret = randomBytes(32);
|
|
53
|
+
const tmp = file + '.tmp-' + randomBytes(4).toString('hex');
|
|
54
|
+
writeFileSync(tmp, secret, { mode: 0o600 });
|
|
55
|
+
renameSync(tmp, file);
|
|
56
|
+
try {
|
|
57
|
+
chmodSync(file, 0o600);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* best effort */
|
|
61
|
+
}
|
|
62
|
+
return secret;
|
|
63
|
+
}
|
|
64
|
+
/** Replace the signing secret (password change): every session dies. */
|
|
65
|
+
export function rotateSecret(dshHome) {
|
|
66
|
+
const file = join(stateDir(dshHome), 'secret');
|
|
67
|
+
const secret = randomBytes(32);
|
|
68
|
+
const tmp = file + '.tmp-' + randomBytes(4).toString('hex');
|
|
69
|
+
writeFileSync(tmp, secret, { mode: 0o600 });
|
|
70
|
+
renameSync(tmp, file);
|
|
71
|
+
try {
|
|
72
|
+
chmodSync(file, 0o600);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* best effort */
|
|
76
|
+
}
|
|
77
|
+
return secret;
|
|
78
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fonlan/dsh-web-auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH web plugin: password-gate authentication (login page + HttpOnly signed cookie) for exposing dsh web behind a reverse proxy",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/fonlan/dsh-web-auth.git"
|
|
10
|
+
},
|
|
11
|
+
"main": "lib/index.js",
|
|
12
|
+
"types": "lib/types/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./lib/index.d.ts",
|
|
16
|
+
"default": "./lib/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./client": {
|
|
19
|
+
"types": "./lib/client/index.d.ts",
|
|
20
|
+
"default": "./lib/client.js"
|
|
21
|
+
},
|
|
22
|
+
"./src/*": "./src/*",
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"lib",
|
|
27
|
+
"cordis.patch.yml"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "rm -rf lib && tsc -p tsconfig.build.json && node scripts/build-client.mjs",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"test": "npm run build && tsc -p tsconfig.test.json && node --test --test-force-exit 'lib-test/*.test.js'",
|
|
36
|
+
"test:only": "tsc -p tsconfig.test.json && node --test --test-force-exit 'lib-test/*.test.js'",
|
|
37
|
+
"prepublishOnly": "npm run build"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"dsh": {
|
|
43
|
+
"bundle": {
|
|
44
|
+
"patch": "./cordis.patch.yml"
|
|
45
|
+
},
|
|
46
|
+
"client": {
|
|
47
|
+
"inject": [
|
|
48
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
50
|
+
],
|
|
51
|
+
"platform": "web"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
56
|
+
"react": "^18.2.0",
|
|
57
|
+
"react-dom": "^18.2.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
61
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
62
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
63
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
|
64
|
+
"@types/node": "^22.0.0",
|
|
65
|
+
"@types/react": "^18.3.0",
|
|
66
|
+
"esbuild": "^0.24.0",
|
|
67
|
+
"react": "^18.2.0",
|
|
68
|
+
"typescript": "^5.6.0"
|
|
69
|
+
}
|
|
70
|
+
}
|