socketduct 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. package/tsconfig.json +20 -0
@@ -0,0 +1,194 @@
1
+ import {spawn} from "node:child_process"
2
+ import {readFile} from "node:fs/promises"
3
+ import {delimiter, dirname, join, resolve} from "node:path"
4
+ import {fileURLToPath, pathToFileURL} from "node:url"
5
+ import {parseEnv} from "node:util"
6
+
7
+ const IMMUTABLE_IMAGE_PATTERN = /^ghcr\.io\/kaspernj\/socketduct@sha256:[0-9a-f]{64}$/
8
+ const PROJECT_DIRECTORY = dirname(dirname(fileURLToPath(import.meta.url)))
9
+
10
+ /** @typedef {Record<string, string | undefined>} ProcessEnvironment */
11
+
12
+ /**
13
+ * @typedef {object} CommandOptions
14
+ * @property {boolean} [captureStdout] - Capture stdout instead of inheriting it.
15
+ * @property {ProcessEnvironment} env - Child process environment.
16
+ */
17
+
18
+ /**
19
+ * @callback CommandRunner
20
+ * @param {string} command
21
+ * @param {string[]} args
22
+ * @param {CommandOptions} options
23
+ * @returns {Promise<string> | string}
24
+ */
25
+
26
+ /**
27
+ * @param {string} command
28
+ * @param {string[]} args
29
+ * @param {CommandOptions} options
30
+ * @returns {Promise<string>} Captured stdout, or an empty string.
31
+ */
32
+ function runCommand(command, args, {captureStdout = false, env}) {
33
+ return new Promise((resolve, reject) => {
34
+ let stdout = ""
35
+ const child = spawn(command, args, {
36
+ env,
37
+ stdio: captureStdout ? ["ignore", "pipe", "inherit"] : "inherit"
38
+ })
39
+ if (captureStdout) {
40
+ child.stdout?.setEncoding("utf8")
41
+ child.stdout?.on("data", (chunk) => { stdout += chunk })
42
+ }
43
+ child.once("error", reject)
44
+ child.once("close", (code, signal) => {
45
+ if (code === 0) {
46
+ resolve(stdout)
47
+ return
48
+ }
49
+ const result = signal === null ? `exit code ${code}` : `signal ${signal}`
50
+ reject(new Error(`${command} failed with ${result}`))
51
+ })
52
+ })
53
+ }
54
+
55
+ /**
56
+ * @param {string | undefined} image
57
+ * @returns {string} Validated immutable image reference.
58
+ */
59
+ export function validateGatewayImage(image) {
60
+ if (image === undefined || !IMMUTABLE_IMAGE_PATTERN.test(image)) {
61
+ throw new Error(
62
+ "SOCKETDUCT_IMAGE must be an immutable GHCR image in the form " +
63
+ "ghcr.io/kaspernj/socketduct@sha256:<64 lowercase hex characters>"
64
+ )
65
+ }
66
+ return image
67
+ }
68
+
69
+ /**
70
+ * @param {string} renderedJson - Rendered Compose JSON held only in memory.
71
+ * @param {string} image - Validated immutable image reference.
72
+ * @returns {void}
73
+ */
74
+ export function validateRenderedGatewayModel(renderedJson, image) {
75
+ /** @type {unknown} */
76
+ let model
77
+ try {
78
+ model = JSON.parse(renderedJson)
79
+ } catch {
80
+ throw new Error("Rendered Compose model is not valid JSON")
81
+ }
82
+ if (typeof model !== "object" || model === null || !("services" in model)) {
83
+ throw new Error("Rendered Compose model does not define socketduct-relay")
84
+ }
85
+ const services = /** @type {{services?: unknown}} */ (model).services
86
+ if (typeof services !== "object" || services === null || !("socketduct-relay" in services)) {
87
+ throw new Error("Rendered Compose model does not define socketduct-relay")
88
+ }
89
+ const serviceMap = /** @type {Record<string, unknown>} */ (services)
90
+ const relay = serviceMap["socketduct-relay"]
91
+ if (typeof relay !== "object" || relay === null) {
92
+ throw new Error("Rendered Compose model has an invalid socketduct-relay service")
93
+ }
94
+ const relayConfig = /** @type {Record<string, unknown>} */ (relay)
95
+ if (Object.hasOwn(relayConfig, "build")) {
96
+ throw new Error("Rendered socketduct-relay service must not contain a build source")
97
+ }
98
+ if (relayConfig.image !== image) {
99
+ throw new Error("Rendered socketduct-relay image does not exactly match validated SOCKETDUCT_IMAGE")
100
+ }
101
+ }
102
+
103
+ /**
104
+ * @param {ProcessEnvironment} environment - Host environment.
105
+ * @param {string} image - Validated immutable image reference.
106
+ * @param {string} composeFile - Fixed production Compose file.
107
+ * @param {string} envFile - Fixed production interpolation environment file.
108
+ * @returns {ProcessEnvironment} Environment with deterministic Compose selectors.
109
+ */
110
+ function composeEnvironment(environment, image, composeFile, envFile) {
111
+ return {
112
+ ...environment,
113
+ COMPOSE_DISABLE_ENV_FILE: "false",
114
+ COMPOSE_ENV_FILES: envFile,
115
+ COMPOSE_FILE: composeFile,
116
+ COMPOSE_IGNORE_ORPHANS: "false",
117
+ COMPOSE_PATH_SEPARATOR: delimiter,
118
+ COMPOSE_PROFILES: "",
119
+ COMPOSE_PROJECT_NAME: "socketduct-gateway",
120
+ COMPOSE_REMOVE_ORPHANS: "false",
121
+ SOCKETDUCT_IMAGE: image
122
+ }
123
+ }
124
+
125
+ /**
126
+ * @param {object} options
127
+ * @param {string | undefined} options.image
128
+ * @param {boolean} [options.dockerHost]
129
+ * @param {ProcessEnvironment} [options.environment]
130
+ * @param {string} [options.projectDirectory]
131
+ * @param {CommandRunner} [options.run]
132
+ * @returns {Promise<void>}
133
+ */
134
+ export async function deployGateway({
135
+ image,
136
+ dockerHost = false,
137
+ environment = process.env,
138
+ projectDirectory = PROJECT_DIRECTORY,
139
+ run = runCommand
140
+ }) {
141
+ const validatedImage = validateGatewayImage(image)
142
+ const projectPath = resolve(projectDirectory)
143
+ const composeFile = join(projectPath, "docker-compose.yml")
144
+ const envFile = join(projectPath, ".env")
145
+ const env = composeEnvironment(environment, validatedImage, composeFile, envFile)
146
+ const compose = [
147
+ "compose",
148
+ "--file", composeFile,
149
+ "--env-file", envFile,
150
+ "--project-directory", projectPath,
151
+ "--project-name", "socketduct-gateway",
152
+ ...(dockerHost ? ["--profile", "docker-host"] : [])
153
+ ]
154
+ const renderedJson = await run("docker", [...compose, "config", "--format", "json"], {captureStdout: true, env})
155
+ validateRenderedGatewayModel(renderedJson, validatedImage)
156
+ await run("docker", [...compose, "pull"], {env})
157
+ await run("docker", [...compose, "up", "-d", "--wait", "--no-build"], {env})
158
+ }
159
+
160
+ /**
161
+ * @returns {Promise<string | undefined>} Configured image reference, if present.
162
+ */
163
+ async function loadGatewayImage() {
164
+ if (Object.hasOwn(process.env, "SOCKETDUCT_IMAGE")) return process.env.SOCKETDUCT_IMAGE
165
+ try {
166
+ const environment = parseEnv(await readFile(join(PROJECT_DIRECTORY, ".env"), "utf8"))
167
+ return environment.SOCKETDUCT_IMAGE
168
+ } catch (error) {
169
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined
170
+ throw error
171
+ }
172
+ }
173
+
174
+ /**
175
+ * @returns {Promise<void>}
176
+ */
177
+ async function main() {
178
+ const arguments_ = process.argv.slice(2)
179
+ const unsupported = arguments_.filter((argument) => argument !== "--docker-host")
180
+ if (unsupported.length > 0 || arguments_.filter((argument) => argument === "--docker-host").length > 1) {
181
+ throw new Error("Usage: npm run gateway:deploy -- [--docker-host]")
182
+ }
183
+ await deployGateway({
184
+ dockerHost: arguments_.includes("--docker-host"),
185
+ image: await loadGatewayImage()
186
+ })
187
+ }
188
+
189
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
190
+ main().catch((error) => {
191
+ console.error(error instanceof Error ? error.message : error)
192
+ process.exitCode = 1
193
+ })
194
+ }
@@ -0,0 +1,28 @@
1
+ import {spawnSync} from "node:child_process"
2
+ import {globSync} from "node:fs"
3
+
4
+ const migrated = ["test/unit/logging/structured-logger.test.js", "test/integration/logging-recovery.test.js"]
5
+ const files = globSync("test/**/*.test.js").sort()
6
+ for (const path of migrated) {
7
+ if (!files.includes(path)) throw new Error(`Missing migrated test module: ${path}`)
8
+ }
9
+ const legacy = files.filter((path) => !migrated.includes(path))
10
+ if (legacy.length === 0) throw new Error("No legacy test modules discovered")
11
+ const commands = [
12
+ {runner: process.execPath, args: ["--test", ...legacy]},
13
+ ...migrated.map((path) => ({runner: "velocious-test", args: [path]}))
14
+ ]
15
+
16
+ if (process.argv.length === 3 && process.argv[2] === "--list") {
17
+ console.log(JSON.stringify({legacy, migrated, commands}, null, 2))
18
+ } else {
19
+ if (process.argv.length !== 2) throw new Error("Only --list is supported; use the owning runner for focused tests")
20
+ for (const command of commands) {
21
+ const result = spawnSync(command.runner, command.args, {stdio: "inherit"})
22
+ if (result.error) throw result.error
23
+ if (result.status !== 0) {
24
+ process.exitCode = result.status ?? 1
25
+ break
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,38 @@
1
+ import {randomBytes, scrypt as scryptCallback, timingSafeEqual} from "node:crypto"
2
+ import {promisify} from "node:util"
3
+
4
+ const scrypt = promisify(scryptCallback)
5
+ const KEY_LENGTH = 64
6
+
7
+ /**
8
+ * @param {string} password - password value.
9
+ * @returns {Promise<string>} - Result value.
10
+ */
11
+ export async function createPasswordHash(password) {
12
+ const salt = randomBytes(16).toString("base64url")
13
+ const key = await scrypt(password, salt, KEY_LENGTH)
14
+ return `scrypt$${salt}$${Buffer.from(key).toString("base64url")}`
15
+ }
16
+
17
+ /**
18
+ * @param {string} password - password value.
19
+ * @param {string} storedHash - storedHash value.
20
+ * @returns {Promise<boolean>} - Result value.
21
+ */
22
+ export async function verifyPassword(password, storedHash) {
23
+ const parts = storedHash.split("$")
24
+ if (parts.length !== 3 || parts[0] !== "scrypt") {
25
+ return false
26
+ }
27
+
28
+ const [, salt, expectedKey] = parts
29
+ if (!salt || !expectedKey) {
30
+ return false
31
+ }
32
+
33
+ const actual = await scrypt(password, salt, KEY_LENGTH)
34
+ const actualBuffer = Buffer.from(actual)
35
+ const expectedBuffer = Buffer.from(expectedKey, "base64url")
36
+
37
+ return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
38
+ }
@@ -0,0 +1,41 @@
1
+ import {createHash} from "node:crypto"
2
+
3
+ /**
4
+ * @typedef {object} StaticTokenPrincipal
5
+ * @property {string} userId - Stable user identifier derived from the username.
6
+ * @property {string} username - Authenticated username.
7
+ * @typedef {object} StaticTokenInput
8
+ * @property {string} username - Username associated with the token.
9
+ * @property {string} token - Opaque bearer token.
10
+ */
11
+
12
+ export class StaticTokenStore {
13
+ /** @type {Map<string, StaticTokenPrincipal>} */
14
+ #principalsByToken = new Map()
15
+
16
+ /** @param {StaticTokenInput[]} tokens - Static bearer tokens. */
17
+ constructor(tokens) {
18
+ for (const token of tokens) {
19
+ this.#principalsByToken.set(token.token, {
20
+ userId: createStableUserId(token.username),
21
+ username: token.username
22
+ })
23
+ }
24
+ }
25
+
26
+ /**
27
+ * @param {string} value - Bearer token value.
28
+ * @returns {StaticTokenPrincipal | undefined} Authenticated principal.
29
+ */
30
+ authenticate(value) {
31
+ return this.#principalsByToken.get(value)
32
+ }
33
+ }
34
+
35
+ /**
36
+ * @param {string} username - Username to hash.
37
+ * @returns {string} Stable user identifier.
38
+ */
39
+ function createStableUserId(username) {
40
+ return `static:${createHash("sha256").update(username).digest("base64url")}`
41
+ }
@@ -0,0 +1,139 @@
1
+ import {randomBytes} from "node:crypto"
2
+
3
+ import {createPasswordHash, verifyPassword} from "./passwords.js"
4
+
5
+ /**
6
+ * @typedef {object} User
7
+ * @property {string} id - id value.
8
+ * @property {string} username - username value.
9
+ * @property {string} passwordHash - passwordHash value.
10
+ * @typedef {object} CreateUserInput
11
+ * @property {string} username - username value.
12
+ * @property {string} password - password value.
13
+ * @typedef {object} LoginInput
14
+ * @property {string} username - username value.
15
+ * @property {string} password - password value.
16
+ * @typedef {object} TokenPrincipal
17
+ * @property {string} userId - userId value.
18
+ * @property {string} username - username value.
19
+ * @typedef {TokenPrincipal & {value: string, expiresAt: number}} AuthToken
20
+ * @typedef {object} TokenStoreOptions
21
+ * @property {() => number} [now] - now value.
22
+ * @property {number} [tokenTtlMs] - tokenTtlMs value.
23
+ */
24
+
25
+ export class InMemoryUserStore {
26
+ /** @type {Map<string, User>} */
27
+ #usersByUsername = new Map()
28
+
29
+ /**
30
+ * @param {CreateUserInput} input - input value.
31
+ * @returns {Promise<User>} - Result value.
32
+ */
33
+ async createUser(input) {
34
+ if (this.#usersByUsername.has(input.username)) {
35
+ throw new Error(`User already exists: ${input.username}`)
36
+ }
37
+
38
+ const user = {
39
+ id: generateOpaqueToken(),
40
+ username: input.username,
41
+ passwordHash: await createPasswordHash(input.password)
42
+ }
43
+ this.#usersByUsername.set(input.username, user)
44
+ return user
45
+ }
46
+
47
+ /**
48
+ * @param {string} username - username value.
49
+ * @returns {User | undefined} - Result value.
50
+ */
51
+ findByUsername(username) {
52
+ return this.#usersByUsername.get(username)
53
+ }
54
+ }
55
+
56
+ export class TokenStore {
57
+ /** @type {Map<string, AuthToken>} */
58
+ #tokens = new Map()
59
+
60
+ /** @type {() => number} */
61
+ #now
62
+
63
+ /** @type {number} */
64
+ #tokenTtlMs
65
+
66
+ /** @param {TokenStoreOptions} [options] - Token store options. */
67
+ constructor(options = {}) {
68
+ this.#now = options.now ?? Date.now
69
+ this.#tokenTtlMs = options.tokenTtlMs ?? 60 * 60 * 1000
70
+ }
71
+
72
+ /**
73
+ * @param {TokenPrincipal} principal - principal value.
74
+ * @returns {AuthToken} - Result value.
75
+ */
76
+ issue(principal) {
77
+ const token = {
78
+ ...principal,
79
+ value: generateOpaqueToken(),
80
+ expiresAt: this.#now() + this.#tokenTtlMs
81
+ }
82
+ this.#tokens.set(token.value, token)
83
+ return token
84
+ }
85
+
86
+ /**
87
+ * @param {string} value - value value.
88
+ * @returns {TokenPrincipal | undefined} - Result value.
89
+ */
90
+ authenticate(value) {
91
+ const token = this.#tokens.get(value)
92
+ if (!token) {
93
+ return undefined
94
+ }
95
+
96
+ if (token.expiresAt <= this.#now()) {
97
+ this.#tokens.delete(value)
98
+ return undefined
99
+ }
100
+
101
+ return {
102
+ userId: token.userId,
103
+ username: token.username
104
+ }
105
+ }
106
+
107
+ /**
108
+ * @param {string} value - value value.
109
+ * @returns {boolean} - Result value.
110
+ */
111
+ revoke(value) {
112
+ return this.#tokens.delete(value)
113
+ }
114
+ }
115
+
116
+ /**
117
+ * @param {InMemoryUserStore} users - users value.
118
+ * @param {TokenStore} tokens - tokens value.
119
+ * @param {LoginInput} input - input value.
120
+ * @returns {Promise<AuthToken>} - Result value.
121
+ */
122
+ export async function login(users, tokens, input) {
123
+ const user = users.findByUsername(input.username)
124
+ if (!user) {
125
+ throw new Error("Invalid username or password")
126
+ }
127
+
128
+ const passwordValid = await verifyPassword(input.password, user.passwordHash)
129
+ if (!passwordValid) {
130
+ throw new Error("Invalid username or password")
131
+ }
132
+
133
+ return tokens.issue({userId: user.id, username: user.username})
134
+ }
135
+
136
+ /** @returns {string} - Opaque token. */
137
+ function generateOpaqueToken() {
138
+ return randomBytes(32).toString("base64url")
139
+ }
@@ -0,0 +1,76 @@
1
+ import {SessionSpool as SessionSpoolStorage} from "../spool/spool-file.js"
2
+ import {connectSocketduct} from "./socketduct-socket.js"
3
+
4
+ /**
5
+ * @typedef {import('./socketduct-socket.js').ConnectSocketductOptions} ConnectSocketductOptions
6
+ * @typedef {import('./socketduct-socket.js').SocketductSocket} SocketductSocket
7
+ * @typedef {import('./socketduct-socket.js').SocketductTlsOptions} SocketductTlsOptions
8
+ * @typedef {{host?: string, port?: number, target?: {host: string, port: number}, sessionName?: string, spoolDirectory?: string, tls?: SocketductTlsOptions, logger?: import('../logging/structured-logger.js').DiagnosticLogger}} SocketductConnectionOptions
9
+ * @typedef {object} SocketductConnectionFactoryOptions
10
+ * @property {{host: string, port: number, token: string}} relay - Relay connection options.
11
+ * @property {string} spoolDirectory - Default spool directory.
12
+ * @property {{initialDelayMs?: number}} [reconnect] - Reconnect options.
13
+ * @property {string} [sessionNamePrefix] - Session name prefix for generated sessions.
14
+ * @property {string} [sessionName] - Default session name.
15
+ * @property {SocketductTlsOptions} [tls] - TLS client options.
16
+ * @property {number} [maxBytes] - Maximum total spool bytes per session.
17
+ * @property {number} [maxAgeMs] - Maximum age of a spool session before operations are rejected.
18
+ * @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured diagnostic logger.
19
+ */
20
+
21
+ /**
22
+ * @param {SocketductConnectionFactoryOptions} baseOptions - Shared Socketduct options.
23
+ * @returns {(options: SocketductConnectionOptions, listener?: () => void) => SocketductSocket} - Socket factory.
24
+ */
25
+ export function createSocketductConnectionFactory(baseOptions) {
26
+ let connectionId = 0
27
+ return (options, listener) => {
28
+ connectionId += 1
29
+ const target = options.target ?? normalizeTarget(options)
30
+ const sessionName = options.sessionName
31
+ ?? baseOptions.sessionName
32
+ ?? `${baseOptions.sessionNamePrefix ?? "socketduct"}-${connectionId}`
33
+
34
+ /** @type {ConnectSocketductOptions} */
35
+ const socketductOptions = {
36
+ relay: baseOptions.relay,
37
+ sessionName,
38
+ target,
39
+ spoolDirectory: options.spoolDirectory ?? baseOptions.spoolDirectory
40
+ }
41
+ if (baseOptions.reconnect !== undefined) {
42
+ socketductOptions.reconnect = baseOptions.reconnect
43
+ }
44
+ if (options.tls !== undefined) {
45
+ socketductOptions.tls = options.tls
46
+ } else if (baseOptions.tls !== undefined) {
47
+ socketductOptions.tls = baseOptions.tls
48
+ }
49
+ if (baseOptions.maxBytes !== undefined) {
50
+ socketductOptions.maxBytes = baseOptions.maxBytes
51
+ }
52
+ if (baseOptions.maxAgeMs !== undefined) {
53
+ socketductOptions.maxAgeMs = baseOptions.maxAgeMs
54
+ }
55
+ if (options.logger !== undefined) {
56
+ socketductOptions.logger = options.logger
57
+ } else if (baseOptions.logger !== undefined) {
58
+ socketductOptions.logger = baseOptions.logger
59
+ }
60
+
61
+ return connectSocketduct(socketductOptions, listener)
62
+ }
63
+ }
64
+
65
+ /**
66
+ * @param {SocketductConnectionOptions} options - Per-connection options.
67
+ * @returns {{host: string, port: number}} - Normalized target.
68
+ */
69
+ function normalizeTarget(options) {
70
+ if (options.host === undefined || options.port === undefined) {
71
+ throw new Error("Socketduct connection requires target or host and port")
72
+ }
73
+ return {host: options.host, port: options.port}
74
+ }
75
+
76
+ export {SessionSpoolStorage}