javascript-solid-server 0.0.136 → 0.0.138

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.
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "servejss",
3
+ "version": "0.0.7",
4
+ "description": "Static file server with REST write support - npx serve alternative",
5
+ "keywords": [
6
+ "serve",
7
+ "static",
8
+ "server",
9
+ "http",
10
+ "rest",
11
+ "webdav",
12
+ "file-server",
13
+ "development",
14
+ "solid"
15
+ ],
16
+ "type": "module",
17
+ "bin": {
18
+ "servejss": "./bin/jsserve.js"
19
+ },
20
+ "files": [
21
+ "bin",
22
+ "src"
23
+ ],
24
+ "scripts": {
25
+ "test": "node --test test/*.test.js"
26
+ },
27
+ "dependencies": {
28
+ "chalk": "^5.3.0",
29
+ "commander": "^12.1.0",
30
+ "javascript-solid-server": ">=0.0.136"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/JavaScriptSolidServer/jsserve.git"
35
+ },
36
+ "homepage": "https://github.com/JavaScriptSolidServer/jsserve#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/JavaScriptSolidServer/jsserve/issues"
39
+ },
40
+ "author": "JavaScriptSolidServer Contributors",
41
+ "license": "MIT",
42
+ "engines": {
43
+ "node": ">=18"
44
+ }
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.136",
3
+ "version": "0.0.138",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,122 @@
1
+ /**
2
+ * TOKEN_SECRET resolution.
3
+ *
4
+ * Extracted from token.js so it can be unit-tested without pulling in the
5
+ * full auth graph (solid-oidc, nostr, webid-tls), which does module-level
6
+ * work that keeps the node:test event loop busy.
7
+ */
8
+
9
+ import crypto from 'crypto';
10
+ import fs from 'fs';
11
+ import os from 'os';
12
+ import path from 'path';
13
+
14
+ export const DEFAULT_SECRET_PATH = path.join(os.homedir(), '.jss', 'token.secret');
15
+
16
+ // Tighten permissions on POSIX, best-effort. No-op on Windows (ACLs) and
17
+ // on read-only filesystems — we never want perm-tightening to block using
18
+ // an otherwise-valid secret.
19
+ function chmodBestEffort(target, mode) {
20
+ try {
21
+ fs.chmodSync(target, mode);
22
+ } catch {
23
+ // Intentionally swallow — perms are defensive hardening, not required.
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Read a persisted secret from `filePath`, or generate one and write it
29
+ * (with dir mode 0700 and file mode 0600) if the file is missing.
30
+ *
31
+ * Read-first: if the file already exists and is non-empty we return it
32
+ * without trying to mkdir or tighten the containing directory. Deployments
33
+ * with a pre-provisioned secret on a read-only filesystem boot cleanly.
34
+ *
35
+ * Concurrent-startup safe: new secrets are written to a per-process temp
36
+ * file in the same directory and `renameSync`'d into place, so another
37
+ * process reading the target never sees a half-written file. If a peer
38
+ * process won the rename we fall back to reading their value.
39
+ *
40
+ * Anything other than ENOENT on the initial read (permission denied,
41
+ * corrupt FS, …) propagates.
42
+ */
43
+ export function readOrWritePersistedSecret(filePath = DEFAULT_SECRET_PATH) {
44
+ const dir = path.dirname(filePath);
45
+
46
+ // Fast path: pre-existing non-empty file. We do not mkdir the parent
47
+ // dir here, and perm-tightening is best-effort (chmodBestEffort swallows
48
+ // all errors), so a pre-provisioned secret on a read-only filesystem
49
+ // still boots cleanly.
50
+ try {
51
+ const existing = fs.readFileSync(filePath, 'utf8').trim();
52
+ if (existing) {
53
+ chmodBestEffort(dir, 0o700);
54
+ chmodBestEffort(filePath, 0o600);
55
+ return existing;
56
+ }
57
+ } catch (e) {
58
+ if (e.code !== 'ENOENT') throw e;
59
+ }
60
+
61
+ // Slow path: create it. Only touch the FS with writes from here on.
62
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
63
+ chmodBestEffort(dir, 0o700);
64
+
65
+ const generated = crypto.randomBytes(32).toString('hex');
66
+ // Atomic write: fully write a temp file, then rename into place. On
67
+ // POSIX the rename is atomic, so concurrent readers see either the old
68
+ // content or the new complete content — never a half-written file.
69
+ const tmpPath = `${filePath}.${crypto.randomBytes(8).toString('hex')}.tmp`;
70
+ try {
71
+ fs.writeFileSync(tmpPath, generated, { mode: 0o600 });
72
+ fs.renameSync(tmpPath, filePath);
73
+ } catch (e) {
74
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
75
+ throw e;
76
+ }
77
+ chmodBestEffort(filePath, 0o600);
78
+
79
+ // Multiple processes racing each produce a different secret; only the
80
+ // last renamer's value sticks on disk. Re-read so every process ends up
81
+ // using the winning secret and token verification stays consistent.
82
+ const persisted = fs.readFileSync(filePath, 'utf8').trim();
83
+ return persisted || generated;
84
+ }
85
+
86
+ /**
87
+ * Resolve the token secret.
88
+ *
89
+ * 1. TOKEN_SECRET env → use it.
90
+ * 2. Else read/create ~/.jss/token.secret.
91
+ * 3. On file-write failure: hard-exit in production, ephemeral secret otherwise.
92
+ *
93
+ * Console I/O is injected so tests can assert log behaviour without spamming
94
+ * the real console; defaults to the real console.
95
+ */
96
+ export function resolveTokenSecret({
97
+ env = process.env,
98
+ secretPath = DEFAULT_SECRET_PATH,
99
+ log = console,
100
+ exit = (code) => process.exit(code),
101
+ } = {}) {
102
+ if (env.TOKEN_SECRET) return env.TOKEN_SECRET;
103
+
104
+ try {
105
+ const s = readOrWritePersistedSecret(secretPath);
106
+ log.warn(`Using persisted TOKEN_SECRET at ${secretPath} (set TOKEN_SECRET env var to override).`);
107
+ return s;
108
+ } catch (e) {
109
+ if (env.NODE_ENV === 'production') {
110
+ const code = e?.code ? ` [${e.code}]` : '';
111
+ log.error(`SECURITY ERROR: TOKEN_SECRET not set and ${secretPath} could not be read or created${code} (${e.message}).`);
112
+ log.error(`Set TOKEN_SECRET explicitly, or grant the necessary access to ${path.dirname(secretPath)}.`);
113
+ exit(1);
114
+ // `exit` is injectable; if a caller stubs it out we must not silently
115
+ // return undefined and let downstream code use an invalid secret.
116
+ throw new Error(`Failed to resolve TOKEN_SECRET in production: ${e.message}`);
117
+ }
118
+ const ephemeral = crypto.randomBytes(32).toString('hex');
119
+ log.warn(`WARNING: Could not persist TOKEN_SECRET (${e.message}). Using ephemeral secret; tokens will not survive restarts.`);
120
+ return ephemeral;
121
+ }
122
+ }
package/src/auth/token.js CHANGED
@@ -11,30 +11,11 @@ import crypto from 'crypto';
11
11
  import { verifySolidOidc, hasSolidOidcAuth } from './solid-oidc.js';
12
12
  import { verifyNostrAuth, hasNostrAuth } from './nostr.js';
13
13
  import { webIdTlsAuth, hasClientCertificate } from './webid-tls.js';
14
+ import { resolveTokenSecret } from './token-secret.js';
14
15
 
15
- // Secret for signing tokens
16
- // SECURITY: In production, TOKEN_SECRET must be set via environment variable
17
- const getSecret = () => {
18
- if (process.env.TOKEN_SECRET) {
19
- return process.env.TOKEN_SECRET;
20
- }
21
-
22
- // In production (NODE_ENV=production), require explicit secret
23
- if (process.env.NODE_ENV === 'production') {
24
- console.error('SECURITY ERROR: TOKEN_SECRET environment variable must be set in production');
25
- console.error('Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
26
- process.exit(1);
27
- }
28
-
29
- // In development, generate a random secret per process (tokens won't survive restarts)
30
- const devSecret = crypto.randomBytes(32).toString('hex');
31
- console.warn('WARNING: No TOKEN_SECRET set. Using random secret (tokens will not survive restarts).');
32
- console.warn('Set TOKEN_SECRET environment variable for persistent tokens.');
33
- return devSecret;
34
- };
35
-
36
- // Initialize secret once at module load
37
- const SECRET = getSecret();
16
+ // Initialize secret once at module load. See token-secret.js for the
17
+ // resolution order (env ~/.jss/token.secret exit-or-ephemeral).
18
+ const SECRET = resolveTokenSecret();
38
19
 
39
20
  /**
40
21
  * Create a simple token for a WebID
package/src/server.js CHANGED
@@ -182,6 +182,8 @@ export function createServer(options = {}) {
182
182
  fastify.decorateRequest('defaultQuota', null);
183
183
  fastify.decorateRequest('config', null);
184
184
  fastify.decorateRequest('liveReloadEnabled', null);
185
+ fastify.decorateRequest('singleUser', null);
186
+ fastify.decorateRequest('singleUserName', null);
185
187
  fastify.addHook('onRequest', async (request) => {
186
188
  request.connegEnabled = connegEnabled;
187
189
  request.notificationsEnabled = notificationsEnabled || liveReloadEnabled;
@@ -195,6 +197,8 @@ export function createServer(options = {}) {
195
197
  request.defaultQuota = defaultQuota;
196
198
  request.config = { public: options.public, readOnly: options.readOnly };
197
199
  request.liveReloadEnabled = liveReloadEnabled;
200
+ request.singleUser = singleUser;
201
+ request.singleUserName = singleUserName;
198
202
 
199
203
  // Extract pod name from subdomain if enabled
200
204
  if (subdomainsEnabled && baseDomain) {
package/src/utils/url.js CHANGED
@@ -150,22 +150,45 @@ export function getResourceName(urlPath) {
150
150
 
151
151
  /**
152
152
  * Extract pod name from URL path or request
153
+ *
154
+ * Resolves to one of four shapes, by deployment mode:
155
+ *
156
+ * - Subdomain mode with a recognized subdomain → `request.podName` (from hostname).
157
+ * - Subdomain mode with no recognized subdomain → `null` (base-domain access;
158
+ * callers guard with `if (podName)` and skip pod-scoped side effects).
159
+ * - Single-user, root-pod (`singleUserName` empty or '/') → `'.'` so
160
+ * `path.join(dataRoot, '.', QUOTA_FILE)` collapses to `<dataRoot>/QUOTA_FILE`.
161
+ * - Single-user, named pod → `singleUserName` (all requests share the one pod,
162
+ * independent of URL — avoids mistaking a URL segment like `index.html`
163
+ * for a pod name).
164
+ * - Path-based multi-pod (default, no flags) → first URL segment, or `null`
165
+ * for requests at `/` that aren't inside any pod.
166
+ *
167
+ * Background: before this function knew about single-user mode, a
168
+ * `PUT /index.html` on a single-user root-pod deployment produced a pod name
169
+ * of `"index.html"`, and the quota sidecar landed at
170
+ * `<dataRoot>/index.html/.quota.json` → `ENOTDIR` (index.html is a file).
171
+ *
153
172
  * @param {string|object} pathOrRequest - URL path string or Fastify request object
154
- * @returns {string|null} - Pod name or null if not found
173
+ * @returns {string|null} - Pod name, `'.'` for root-pod, or `null` when no pod applies
155
174
  */
156
175
  export function getPodName(pathOrRequest) {
157
- // If it's a request object
158
- if (typeof pathOrRequest === 'object') {
159
- // Subdomain mode: pod name from hostname
160
- if (pathOrRequest.subdomainsEnabled && pathOrRequest.podName) {
161
- return pathOrRequest.podName;
176
+ if (typeof pathOrRequest === 'object' && pathOrRequest !== null) {
177
+ // Subdomain mode: hostname drives it. Unrecognized host → no pod.
178
+ if (pathOrRequest.subdomainsEnabled) {
179
+ return pathOrRequest.podName || null;
162
180
  }
163
- // Path mode: extract from URL
181
+ // Single-user mode: always the one pod, regardless of URL path.
182
+ if (pathOrRequest.singleUser) {
183
+ const name = pathOrRequest.singleUserName;
184
+ return (!name || name === '/') ? '.' : name;
185
+ }
186
+ // Path-based multi-pod: first URL segment.
164
187
  const urlPath = pathOrRequest.url?.split('?')[0] || '';
165
188
  return getPodNameFromPath(urlPath);
166
189
  }
167
190
 
168
- // If it's a string path
191
+ // String form: path-based pod extraction.
169
192
  return getPodNameFromPath(pathOrRequest);
170
193
  }
171
194
 
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Unit tests for TOKEN_SECRET resolution (src/auth/token-secret.js).
3
+ *
4
+ * Covers #280: TOKEN_SECRET auto-persists on first run rather than hard-exiting.
5
+ */
6
+
7
+ import { describe, it, before, after } from 'node:test';
8
+ import assert from 'node:assert';
9
+ import fs from 'fs';
10
+ import os from 'os';
11
+ import path from 'path';
12
+ import {
13
+ readOrWritePersistedSecret,
14
+ resolveTokenSecret,
15
+ DEFAULT_SECRET_PATH,
16
+ } from '../src/auth/token-secret.js';
17
+
18
+ describe('readOrWritePersistedSecret', () => {
19
+ let tmpDir;
20
+ let secretPath;
21
+
22
+ before(() => {
23
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jss-token-secret-'));
24
+ secretPath = path.join(tmpDir, '.jss', 'token.secret');
25
+ });
26
+
27
+ after(() => {
28
+ fs.rmSync(tmpDir, { recursive: true, force: true });
29
+ });
30
+
31
+ it('generates + persists a secret when the file is missing', () => {
32
+ const s = readOrWritePersistedSecret(secretPath);
33
+ assert.strictEqual(typeof s, 'string');
34
+ assert.strictEqual(s.length, 64); // 32 bytes, hex-encoded
35
+ assert.strictEqual(fs.readFileSync(secretPath, 'utf8').trim(), s);
36
+ });
37
+
38
+ it('returns the same secret on subsequent calls', () => {
39
+ const first = readOrWritePersistedSecret(secretPath);
40
+ const second = readOrWritePersistedSecret(secretPath);
41
+ assert.strictEqual(first, second);
42
+ });
43
+
44
+ it('enforces tight permissions on POSIX (skipped on Windows)', { skip: process.platform === 'win32' }, () => {
45
+ const stat = fs.statSync(secretPath);
46
+ assert.strictEqual(stat.mode & 0o777, 0o600, 'secret file should be mode 0600');
47
+ const dirStat = fs.statSync(path.dirname(secretPath));
48
+ assert.strictEqual(dirStat.mode & 0o777, 0o700, 'secret dir should be mode 0700');
49
+ });
50
+
51
+ it('propagates errors other than ENOENT', () => {
52
+ // Use a regular file as the would-be parent directory — mkdirSync then
53
+ // fails with ENOTDIR synchronously. Portable across OSes.
54
+ const blockerFile = path.join(tmpDir, 'blocker-file');
55
+ fs.writeFileSync(blockerFile, 'not a dir');
56
+ const unwritable = path.join(blockerFile, '.jss', 'token.secret');
57
+ assert.throws(() => readOrWritePersistedSecret(unwritable));
58
+ });
59
+
60
+ it('recovers when the secret file already exists but is empty', () => {
61
+ // Simulates a concurrent or interrupted persistence case: the file
62
+ // is present (so the fast path falls through the trim-empty check)
63
+ // but carries no usable secret yet. tmp-file + renameSync repairs
64
+ // it by overwriting atomically.
65
+ const p = path.join(tmpDir, 'empty', '.jss', 'token.secret');
66
+ fs.mkdirSync(path.dirname(p), { recursive: true });
67
+ fs.writeFileSync(p, '');
68
+ const s = readOrWritePersistedSecret(p);
69
+ assert.strictEqual(s.length, 64);
70
+ assert.strictEqual(fs.readFileSync(p, 'utf8').trim(), s);
71
+ });
72
+
73
+ it('tightens permissions when the file already exists with loose mode', { skip: process.platform === 'win32' }, () => {
74
+ const p = path.join(tmpDir, 'loose', '.jss', 'token.secret');
75
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o755 });
76
+ fs.writeFileSync(p, 'a'.repeat(64), { mode: 0o644 });
77
+ readOrWritePersistedSecret(p);
78
+ assert.strictEqual(fs.statSync(p).mode & 0o777, 0o600);
79
+ assert.strictEqual(fs.statSync(path.dirname(p)).mode & 0o777, 0o700);
80
+ });
81
+
82
+ it('reads a pre-existing secret even when the parent dir is not writable', { skip: process.platform === 'win32' || process.getuid?.() === 0 }, () => {
83
+ // Simulates a read-only deployment: secret provisioned ahead of time,
84
+ // parent dir not writable for the current user. Must not block startup.
85
+ const p = path.join(tmpDir, 'readonly-parent', '.jss', 'token.secret');
86
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
87
+ const expected = 'b'.repeat(64);
88
+ fs.writeFileSync(p, expected);
89
+ fs.chmodSync(path.dirname(p), 0o500); // r-x, no write
90
+ try {
91
+ const s = readOrWritePersistedSecret(p);
92
+ assert.strictEqual(s, expected);
93
+ } finally {
94
+ fs.chmodSync(path.dirname(p), 0o700); // let after()'s rmSync clean up
95
+ }
96
+ });
97
+ });
98
+
99
+ describe('resolveTokenSecret', () => {
100
+ let tmpDir;
101
+
102
+ before(() => {
103
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jss-resolve-secret-'));
104
+ });
105
+
106
+ after(() => {
107
+ fs.rmSync(tmpDir, { recursive: true, force: true });
108
+ });
109
+
110
+ const silentLog = { warn: () => {}, error: () => {} };
111
+
112
+ it('prefers TOKEN_SECRET env var', () => {
113
+ const s = resolveTokenSecret({
114
+ env: { TOKEN_SECRET: 'from-env' },
115
+ secretPath: path.join(tmpDir, 'unused', 'token.secret'),
116
+ log: silentLog,
117
+ });
118
+ assert.strictEqual(s, 'from-env');
119
+ });
120
+
121
+ it('persists a generated secret when env is unset', () => {
122
+ const p = path.join(tmpDir, 'persist', 'token.secret');
123
+ const s = resolveTokenSecret({ env: {}, secretPath: p, log: silentLog });
124
+ assert.strictEqual(s.length, 64);
125
+ assert.strictEqual(fs.readFileSync(p, 'utf8').trim(), s);
126
+ });
127
+
128
+ it('returns the same persisted secret on the next call', () => {
129
+ const p = path.join(tmpDir, 'persist-twice', 'token.secret');
130
+ const first = resolveTokenSecret({ env: {}, secretPath: p, log: silentLog });
131
+ const second = resolveTokenSecret({ env: {}, secretPath: p, log: silentLog });
132
+ assert.strictEqual(first, second);
133
+ });
134
+
135
+ // Build an unwritable path by planting a regular file where the helper
136
+ // would try to mkdir a directory. mkdirSync then fails synchronously.
137
+ function buildUnwritable(name) {
138
+ const blocker = path.join(tmpDir, name, 'blocker-file');
139
+ fs.mkdirSync(path.dirname(blocker), { recursive: true });
140
+ fs.writeFileSync(blocker, 'not a dir');
141
+ return path.join(blocker, '.jss', 'token.secret');
142
+ }
143
+
144
+ it('hard-exits in production when persistence fails', () => {
145
+ let exitCode;
146
+ assert.throws(() => {
147
+ resolveTokenSecret({
148
+ env: { NODE_ENV: 'production' },
149
+ secretPath: buildUnwritable('prod'),
150
+ log: silentLog,
151
+ exit: (code) => { exitCode = code; }, // stubbed — doesn't actually terminate
152
+ });
153
+ });
154
+ // exit(1) must still have been invoked even though we throw afterwards,
155
+ // so a non-stubbed production process actually terminates.
156
+ assert.strictEqual(exitCode, 1);
157
+ });
158
+
159
+ it('throws after exit so a stubbed exit() cannot leak undefined downstream', () => {
160
+ // Regression: earlier versions returned undefined "for tests" after
161
+ // calling exit(), which could let callers continue with an invalid
162
+ // secret when exit is stubbed.
163
+ assert.throws(
164
+ () => resolveTokenSecret({
165
+ env: { NODE_ENV: 'production' },
166
+ secretPath: buildUnwritable('no-leak'),
167
+ log: silentLog,
168
+ exit: () => {},
169
+ }),
170
+ /TOKEN_SECRET/
171
+ );
172
+ });
173
+
174
+ it('production error message references the actual secret directory', () => {
175
+ const secretPath = buildUnwritable('custom-path');
176
+ const errors = [];
177
+ assert.throws(() => {
178
+ resolveTokenSecret({
179
+ env: { NODE_ENV: 'production' },
180
+ secretPath,
181
+ log: { warn: () => {}, error: (msg) => errors.push(msg) },
182
+ exit: () => {},
183
+ });
184
+ });
185
+ assert.ok(
186
+ errors.some(m => m.includes(path.dirname(secretPath))),
187
+ `expected an error to mention ${path.dirname(secretPath)}, got: ${errors.join(' | ')}`
188
+ );
189
+ });
190
+
191
+ it('falls back to an ephemeral secret outside production when persistence fails', () => {
192
+ const s = resolveTokenSecret({
193
+ env: {},
194
+ secretPath: buildUnwritable('dev'),
195
+ log: silentLog,
196
+ exit: () => { throw new Error('exit should not be called in dev') },
197
+ });
198
+ assert.strictEqual(typeof s, 'string');
199
+ assert.strictEqual(s.length, 64);
200
+ });
201
+ });
202
+
203
+ describe('DEFAULT_SECRET_PATH', () => {
204
+ it('is absolute and platform-native', () => {
205
+ assert.ok(path.isAbsolute(DEFAULT_SECRET_PATH));
206
+ assert.ok(DEFAULT_SECRET_PATH.includes('.jss'));
207
+ assert.ok(DEFAULT_SECRET_PATH.includes('token.secret'));
208
+ });
209
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Unit tests for src/utils/url.js
3
+ *
4
+ * Focus: getPodName() resolution across the four supported deployment modes.
5
+ * Regression guard for #278 (single-user root-pod PUT → ENOTDIR).
6
+ */
7
+
8
+ import { describe, it } from 'node:test';
9
+ import assert from 'node:assert';
10
+ import { getPodName } from '../src/utils/url.js';
11
+
12
+ describe('getPodName', () => {
13
+ describe('subdomain mode', () => {
14
+ it('returns request.podName when the subdomain is recognized', () => {
15
+ const req = { subdomainsEnabled: true, podName: 'alice', url: '/profile/card' };
16
+ assert.strictEqual(getPodName(req), 'alice');
17
+ });
18
+
19
+ it('returns null on base-domain access (no recognized subdomain)', () => {
20
+ const req = { subdomainsEnabled: true, podName: null, url: '/anything' };
21
+ assert.strictEqual(getPodName(req), null);
22
+ });
23
+ });
24
+
25
+ describe('single-user mode', () => {
26
+ it("returns '.' for a root pod (singleUserName empty)", () => {
27
+ const req = { singleUser: true, singleUserName: '', url: '/index.html' };
28
+ assert.strictEqual(getPodName(req), '.');
29
+ });
30
+
31
+ it("returns '.' for a root pod (singleUserName '/')", () => {
32
+ const req = { singleUser: true, singleUserName: '/', url: '/index.html' };
33
+ assert.strictEqual(getPodName(req), '.');
34
+ });
35
+
36
+ it('returns singleUserName for a named pod, regardless of URL', () => {
37
+ const req = { singleUser: true, singleUserName: 'me', url: '/index.html' };
38
+ assert.strictEqual(getPodName(req), 'me');
39
+ });
40
+
41
+ it('does not mistake a URL segment for a pod in single-user mode', () => {
42
+ // Regression for #278: PUT /index.html previously produced pod
43
+ // "index.html", making the quota sidecar path <dataRoot>/index.html/.quota.json.
44
+ const req = { singleUser: true, singleUserName: '', url: '/index.html' };
45
+ assert.notStrictEqual(getPodName(req), 'index.html');
46
+ });
47
+ });
48
+
49
+ describe('path-based multi-pod (default)', () => {
50
+ it('returns the first URL segment as the pod name', () => {
51
+ const req = { url: '/alice/profile/card' };
52
+ assert.strictEqual(getPodName(req), 'alice');
53
+ });
54
+
55
+ it('returns null for requests at /', () => {
56
+ const req = { url: '/' };
57
+ assert.strictEqual(getPodName(req), null);
58
+ });
59
+
60
+ it('skips system paths beginning with a dot', () => {
61
+ const req = { url: '/.well-known/openid-configuration' };
62
+ assert.strictEqual(getPodName(req), null);
63
+ });
64
+ });
65
+
66
+ describe('string-form input', () => {
67
+ it('extracts pod name from a URL path string', () => {
68
+ assert.strictEqual(getPodName('/alice/foo'), 'alice');
69
+ });
70
+
71
+ it('returns null for the root path', () => {
72
+ assert.strictEqual(getPodName('/'), null);
73
+ });
74
+ });
75
+ });