@hasna/recordings 0.3.13 → 0.5.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.
Files changed (148) hide show
  1. package/README.md +279 -43
  2. package/bun.lock +37 -38
  3. package/contracts/v1/fixtures.json +1206 -0
  4. package/dist/cli/index.js +1909 -619
  5. package/dist/cli/macos-shortcut.d.ts +2 -2
  6. package/dist/contracts/hosted-v1.d.ts +105 -0
  7. package/dist/contracts/hosted-v1.d.ts.map +1 -0
  8. package/dist/contracts/hosted-v1.js +41 -0
  9. package/dist/contracts/stream-v1.d.ts +117 -0
  10. package/dist/contracts/stream-v1.d.ts.map +1 -0
  11. package/dist/contracts/stream-v1.js +35 -0
  12. package/dist/db/database.d.ts.map +1 -1
  13. package/dist/db/pg-migrations.d.ts.map +1 -1
  14. package/dist/db/recordings.d.ts +6 -0
  15. package/dist/db/recordings.d.ts.map +1 -1
  16. package/dist/hosted/index.d.ts +58 -0
  17. package/dist/hosted/index.d.ts.map +1 -0
  18. package/dist/hosted/index.js +266 -0
  19. package/dist/hosted/transport.d.ts +36 -0
  20. package/dist/hosted/transport.d.ts.map +1 -0
  21. package/dist/hosted-v1-aavn7ktb.js +4114 -0
  22. package/dist/hosted-v1-gdr9extc.js +84 -0
  23. package/dist/http/client.d.ts +183 -22
  24. package/dist/http/client.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2879 -2288
  28. package/dist/lib/audio-artifact-storage.d.ts +98 -0
  29. package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
  30. package/dist/lib/capture-probe.d.ts +3 -3
  31. package/dist/lib/capture-probe.d.ts.map +1 -1
  32. package/dist/lib/config.d.ts.map +1 -1
  33. package/dist/lib/local-opt-in.d.ts +132 -0
  34. package/dist/lib/local-opt-in.d.ts.map +1 -0
  35. package/dist/lib/macos-bundle.d.ts +1 -1
  36. package/dist/lib/persistence-probe.d.ts +56 -17
  37. package/dist/lib/persistence-probe.d.ts.map +1 -1
  38. package/dist/lib/transcriber.d.ts.map +1 -1
  39. package/dist/mcp/index.d.ts.map +1 -1
  40. package/dist/mcp/index.js +1713 -459
  41. package/dist/sdk/index.d.ts +13 -5
  42. package/dist/sdk/index.d.ts.map +1 -1
  43. package/dist/sdk/index.js +904 -1
  44. package/dist/sdk/resolve.d.ts +53 -0
  45. package/dist/sdk/resolve.d.ts.map +1 -0
  46. package/dist/sdk/v1.generated.d.ts +6 -0
  47. package/dist/sdk/v1.generated.d.ts.map +1 -1
  48. package/dist/server/client-ip.d.ts +39 -0
  49. package/dist/server/client-ip.d.ts.map +1 -0
  50. package/dist/server/cloud-config.d.ts +12 -0
  51. package/dist/server/cloud-config.d.ts.map +1 -1
  52. package/dist/server/cloud-readiness.d.ts.map +1 -1
  53. package/dist/server/cloud.d.ts +20 -2
  54. package/dist/server/cloud.d.ts.map +1 -1
  55. package/dist/server/index.js +590 -287
  56. package/dist/server/migrate-command.d.ts +1 -1
  57. package/dist/server/migrate-command.d.ts.map +1 -1
  58. package/dist/server/openapi.d.ts +21 -0
  59. package/dist/server/openapi.d.ts.map +1 -1
  60. package/dist/server/repo.d.ts.map +1 -1
  61. package/dist/server/serve.d.ts.map +1 -1
  62. package/dist/storage.d.ts +2 -2
  63. package/dist/storage.d.ts.map +1 -1
  64. package/dist/storage.js +1358 -874
  65. package/dist/store.d.ts +12 -3
  66. package/dist/store.d.ts.map +1 -1
  67. package/dist/types/index.d.ts +15 -0
  68. package/dist/types/index.d.ts.map +1 -1
  69. package/docs/hosted-sdk.md +71 -0
  70. package/docs/wire-contracts.md +24 -0
  71. package/package.json +33 -11
  72. package/packaging/macos/build_release_pkg.sh +3 -3
  73. package/packaging/macos/managed_bootstrap.sh +4 -4
  74. package/packaging/macos/scripts/postinstall +2 -2
  75. package/packaging/macos/scripts/preinstall +2 -2
  76. package/scripts/build_companion_cli.sh +7 -1
  77. package/scripts/ci-linux-suite.ts +23 -13
  78. package/scripts/install_macos_app.sh +27 -24
  79. package/scripts/macos_artifact.ts +60 -49
  80. package/scripts/migrate.ts +4 -20
  81. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  82. package/scripts/native/recordings_fs_guard.c +36 -4
  83. package/scripts/native-core-receipt.py +171 -0
  84. package/scripts/native_fs_guard.ts +2 -0
  85. package/scripts/release-suite-gate.ts +227 -150
  86. package/scripts/resolve_tailscale_cli.sh +24 -3
  87. package/scripts/smoke_macos_app.sh +21 -21
  88. package/src/native/Recordings/App/ContentView.swift +53 -68
  89. package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
  90. package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
  91. package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
  92. package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
  93. package/src/native/Recordings/App/RecordingsApp.swift +185 -9
  94. package/src/native/Recordings/App/RecordingsListView.swift +60 -112
  95. package/src/native/Recordings/App/RecordingsStore.swift +124 -109
  96. package/src/native/Recordings/App/Theme.swift +73 -61
  97. package/src/native/Recordings/Package.swift +1 -0
  98. package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
  99. package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
  101. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
  102. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
  103. package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
  104. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
  105. package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
  106. package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
  107. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
  108. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
  109. package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
  110. package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
  111. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
  112. package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
  113. package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
  114. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
  115. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
  116. package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
  117. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
  118. package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
  119. package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
  120. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
  121. package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
  122. package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
  123. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +308 -0
  124. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
  125. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
  126. package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
  127. package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
  128. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
  129. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
  130. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
  131. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
  132. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
  133. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
  134. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
  135. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
  136. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
  137. package/src/native/Recordings/build.sh +8 -7
  138. package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
  139. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
  140. package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
  141. package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
  142. package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
  143. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
  144. package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
  145. package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
  146. package/dist/__tests__/preload.d.ts +0 -2
  147. package/dist/__tests__/preload.d.ts.map +0 -1
  148. package/src/native/Recordings/App/SidebarView.swift +0 -212
@@ -15,14 +15,13 @@ var __export = (target, all) => {
15
15
  });
16
16
  };
17
17
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
18
- var __require = import.meta.require;
19
18
 
20
19
  // package.json
21
20
  var package_default;
22
21
  var init_package = __esm(() => {
23
22
  package_default = {
24
23
  name: "@hasna/recordings",
25
- version: "0.3.13",
24
+ version: "0.5.0",
26
25
  type: "module",
27
26
  description: "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
28
27
  repository: {
@@ -48,6 +47,19 @@ var init_package = __esm(() => {
48
47
  "./sdk": {
49
48
  import: "./dist/sdk/index.js",
50
49
  types: "./dist/sdk/index.d.ts"
50
+ },
51
+ "./contracts/hosted-v1": {
52
+ import: "./dist/contracts/hosted-v1.js",
53
+ types: "./dist/contracts/hosted-v1.d.ts"
54
+ },
55
+ "./contracts/stream-v1": {
56
+ import: "./dist/contracts/stream-v1.js",
57
+ types: "./dist/contracts/stream-v1.d.ts"
58
+ },
59
+ "./contracts/fixtures/v1": "./contracts/v1/fixtures.json",
60
+ "./hosted": {
61
+ import: "./dist/hosted/index.js",
62
+ types: "./dist/hosted/index.d.ts"
51
63
  }
52
64
  },
53
65
  engines: {
@@ -56,20 +68,20 @@ var init_package = __esm(() => {
56
68
  scripts: {
57
69
  clean: "rm -rf dist",
58
70
  build: "bun run clean && bun run build:cli && bun run build:mcp && bun run build:serve && bun run build:lib && tsc --emitDeclarationOnly --outDir dist",
59
- "build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai",
60
- "build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai",
61
- "build:serve": "bun build src/server/index.ts --target=bun --outfile=dist/server/index.js --external=@modelcontextprotocol/sdk --external=@hasna/contracts --external=openai --external=pg",
62
- "build:lib": "bun build src/index.ts src/storage.ts src/sdk/index.ts --target=bun --outdir=dist --external=openai",
71
+ "build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai --external=@aws-sdk/client-s3",
72
+ "build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai --external=@aws-sdk/client-s3",
73
+ "build:serve": "bun build src/server/index.ts --target=bun --outfile=dist/server/index.js --external=@modelcontextprotocol/sdk --external=@hasna/contracts --external=openai --external=pg --external=@aws-sdk/client-s3",
74
+ "build:lib": "bun build src/index.ts src/storage.ts src/sdk/index.ts --target=bun --outdir=dist --external=openai --external=@aws-sdk/client-s3 && bun run build:contracts",
63
75
  "build:native-fs-guard": "/bin/bash scripts/build_native_fs_guard.sh",
64
76
  "generate:sdk": "bun run scripts/generate-sdk.ts",
65
77
  migrate: "bun run scripts/migrate.ts",
66
78
  typecheck: "tsc --noEmit",
67
79
  "typecheck:tcc-contract": "tsc --noEmit -p tsconfig.tcc-contract.json",
68
- test: "bun test",
80
+ test: "bun scripts/release-suite-gate.ts --all",
69
81
  "verify:ci-suite": "bun scripts/ci-linux-suite.ts --check",
70
82
  "verify:ci-suite:run": "bun scripts/ci-linux-suite.ts --verify-run",
71
83
  "verify:ci-native": "bun scripts/ci-native-build.ts",
72
- "test:gated": 'bun scripts/ci-linux-suite.ts --check && RECORDINGS_TEST_TIMEOUT_MS="${RECORDINGS_TEST_TIMEOUT_MS:-120000}" bun test --timeout "$RECORDINGS_TEST_TIMEOUT_MS" $(bun scripts/ci-linux-suite.ts --gated)',
84
+ "test:gated": "bun scripts/release-suite-gate.ts",
73
85
  "test:vacuity-battery": 'bun scripts/vacuity-manifest-gen.ts > "${TMPDIR:-/tmp}/vacuity-corrupt-sites.tsv" && bun scripts/vacuity-mutation-battery.ts "${TMPDIR:-/tmp}/vacuity-corrupt-sites.tsv"',
74
86
  "test:vacuity-battery:source": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/source-side.tsv",
75
87
  "test:vacuity-battery:chain": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/install-chain.tsv",
@@ -87,11 +99,16 @@ var init_package = __esm(() => {
87
99
  "prepack:platform-gate": `bash -c 'if [ "$(uname -s)" = Darwin ]; then bun run build:native-fs-guard; else echo "WARN: native fs-guard build skipped on $(uname -s) \u2014 dry-run/CI pack; publish recordings from macOS"; fi'`,
88
100
  prepublishOnly: "bun run typecheck && bun run release-suite-gate",
89
101
  "release-suite-gate": "bun run scripts/release-suite-gate.ts",
90
- "typecheck:shortcut-contract": "tsc --noEmit -p tsconfig.test.json"
102
+ "typecheck:shortcut-contract": "tsc --noEmit -p tsconfig.test.json",
103
+ "build:contracts": "bun build src/contracts/hosted-v1.ts src/contracts/stream-v1.ts src/hosted/index.ts --target=bun --root src --outdir=dist --splitting --external=@hasna/contracts"
91
104
  },
92
105
  files: [
93
106
  "dist/",
107
+ "!dist/__tests__/",
94
108
  "scripts/",
109
+ "!scripts/test-recorder-fixtures.ts",
110
+ "!scripts/**/__pycache__/",
111
+ "!scripts/**/*.pyc",
95
112
  "Dockerfile.package",
96
113
  "bun.lock",
97
114
  "src/native/Recordings/App/",
@@ -103,10 +120,14 @@ var init_package = __esm(() => {
103
120
  "src/native/Recordings/build.sh",
104
121
  "packaging/macos/",
105
122
  "README.md",
106
- "LICENSE"
123
+ "LICENSE",
124
+ "contracts/v1/fixtures.json",
125
+ "docs/wire-contracts.md",
126
+ "docs/hosted-sdk.md"
107
127
  ],
108
128
  dependencies: {
109
- "@hasna/contracts": "0.14.1",
129
+ "@aws-sdk/client-s3": "^3.1007.0",
130
+ "@hasna/contracts": "1.0.2",
110
131
  "@hasna/events": "0.1.11",
111
132
  "@modelcontextprotocol/sdk": "^1.12.1",
112
133
  chalk: "^5.4.1",
@@ -116,7 +137,7 @@ var init_package = __esm(() => {
116
137
  zod: "^3.24.2"
117
138
  },
118
139
  devDependencies: {
119
- "@types/bun": "^1.2.5",
140
+ "@types/bun": "1.3.14",
120
141
  "@types/pg": "^8.11.11",
121
142
  "node-api-headers": "1.9.0",
122
143
  typescript: "^5.8.2"
@@ -203,6 +224,9 @@ var init_remote_storage = () => {};
203
224
  function resolveDatabaseUrl(env = process.env) {
204
225
  return env.HASNA_RECORDINGS_DATABASE_URL || env.RECORDINGS_DATABASE_URL || env.DATABASE_URL || undefined;
205
226
  }
227
+ function resolveMigrationDatabaseUrl(env = process.env) {
228
+ return env.HASNA_RECORDINGS_MIGRATE_DATABASE_URL || env.RECORDINGS_MIGRATE_DATABASE_URL || resolveDatabaseUrl(env);
229
+ }
206
230
  function resolveDataBackend(env = process.env) {
207
231
  return resolveDatabaseUrl(env) ? "postgresql" : "sqlite";
208
232
  }
@@ -210,7 +234,7 @@ function isPostgresBackendEnabled(env = process.env) {
210
234
  return resolveDataBackend(env) === "postgresql";
211
235
  }
212
236
  function resolveSigningSecret(env = process.env) {
213
- return env.HASNA_RECORDINGS_API_SIGNING_KEY || env.HASNA_API_SIGNING_KEY || env.API_KEY_SIGNING_SECRET || undefined;
237
+ return env.HASNA_RECORDINGS_API_SIGNING_KEY?.trim() || env.HASNA_API_SIGNING_KEY?.trim() || env.API_KEY_SIGNING_SECRET?.trim() || undefined;
214
238
  }
215
239
  function requireSigningSecret(env = process.env) {
216
240
  const signingSecret = resolveSigningSecret(env);
@@ -224,13 +248,6 @@ function requireSigningSecret(env = process.env) {
224
248
  }
225
249
 
226
250
  // src/server/cloud-readiness.ts
227
- var exports_cloud_readiness = {};
228
- __export(exports_cloud_readiness, {
229
- pingCloudReadiness: () => pingCloudReadiness,
230
- pingCloudConnectivity: () => pingCloudConnectivity,
231
- assertCloudSchemaReady: () => assertCloudSchemaReady,
232
- assertCloudSchemaContract: () => assertCloudSchemaContract
233
- });
234
251
  function sameColumns(actual, expected) {
235
252
  return actual.length === expected.length && actual.every((column, index) => column === expected[index]);
236
253
  }
@@ -365,6 +382,16 @@ async function assertCloudSchemaReady(pg2) {
365
382
  AND has_schema_privilege(current_user, schema_row.oid, 'CREATE')
366
383
  ) AS can_create_in_user_schema,
367
384
  has_schema_privilege(current_user, 'public', 'USAGE') AS can_use_public,
385
+ COALESCE((
386
+ SELECT has_column_privilege(table_row.relowner, table_row.oid, 'recording_id', 'SELECT') AND
387
+ has_column_privilege(table_row.relowner, table_row.oid, 'recording_id', 'UPDATE')
388
+ FROM pg_class table_row WHERE table_row.oid = 'public.recording_idempotency'::regclass
389
+ ), false) AS owner_can_tombstone_recording_idempotency,
390
+ COALESCE((
391
+ SELECT has_column_privilege(table_row.relowner, table_row.oid, 'recording_id', 'SELECT') AND
392
+ has_table_privilege(table_row.relowner, table_row.oid, 'DELETE')
393
+ FROM pg_class table_row WHERE table_row.oid = 'public.recording_tags'::regclass
394
+ ), false) AS owner_can_delete_recording_tags,
368
395
  EXISTS (
369
396
  SELECT 1
370
397
  FROM pg_class table_row
@@ -451,6 +478,12 @@ async function assertCloudSchemaReady(pg2) {
451
478
  if (excessive.length > 0) {
452
479
  throw new Error(`cloud database role has surplus table privileges beyond runtime use: ${excessive.join(", ")}`);
453
480
  }
481
+ if (!row?.owner_can_tombstone_recording_idempotency) {
482
+ throw new Error("cloud database recording_idempotency owner lacks SELECT/UPDATE privileges required for recording deletion");
483
+ }
484
+ if (!row?.owner_can_delete_recording_tags) {
485
+ throw new Error("cloud database recording_tags owner lacks SELECT/DELETE privileges required for recording deletion");
486
+ }
454
487
  }
455
488
  async function pingCloudConnectivity(pg2) {
456
489
  const res = await pg2.get("SELECT 1 as ok");
@@ -488,6 +521,9 @@ var init_cloud_readiness = __esm(() => {
488
521
  id: ["text", false],
489
522
  audio_path: ["text", true],
490
523
  raw_text: ["text", false],
524
+ audio_object_key: ["text", true],
525
+ audio_sha256: ["text", true],
526
+ audio_bytes: ["bigint", true],
491
527
  processed_text: ["text", true],
492
528
  processing_mode: ["text", false],
493
529
  model_used: ["text", false],
@@ -694,16 +730,14 @@ var init_pg_migrations = __esm(() => {
694
730
  ALTER COLUMN recording_id DROP NOT NULL;
695
731
  ALTER TABLE recording_idempotency
696
732
  ADD CONSTRAINT recording_idempotency_recording_id_fkey
697
- FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE SET NULL`
733
+ FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE SET NULL`,
734
+ `ALTER TABLE recordings ADD COLUMN IF NOT EXISTS audio_object_key TEXT`,
735
+ `ALTER TABLE recordings ADD COLUMN IF NOT EXISTS audio_sha256 TEXT`,
736
+ `ALTER TABLE recordings ADD COLUMN IF NOT EXISTS audio_bytes BIGINT`
698
737
  ];
699
738
  });
700
739
 
701
740
  // src/server/migrate-command.ts
702
- var exports_migrate_command = {};
703
- __export(exports_migrate_command, {
704
- runCloudMigration: () => runCloudMigration,
705
- applyRecordedCloudMigrations: () => applyRecordedCloudMigrations
706
- });
707
741
  async function applyRecordedCloudMigrations(pg2, ensureApiKeySchema) {
708
742
  await pg2.run(`CREATE TABLE IF NOT EXISTS _pg_migrations (id SERIAL PRIMARY KEY, version INT UNIQUE NOT NULL, applied_at TIMESTAMPTZ DEFAULT NOW())`);
709
743
  const applied = await pg2.all("SELECT version FROM _pg_migrations ORDER BY version");
@@ -720,10 +754,12 @@ async function applyRecordedCloudMigrations(pg2, ensureApiKeySchema) {
720
754
  }
721
755
  await ensureApiKeySchema();
722
756
  }
723
- async function runCloudMigration(steps) {
757
+ async function runCloudMigration(steps, runtimePostureValidate) {
724
758
  await steps.pingConnectivity();
725
759
  await steps.applyMigrations();
726
760
  await steps.validateContract();
761
+ if (runtimePostureValidate)
762
+ await runtimePostureValidate();
727
763
  }
728
764
  var init_migrate_command = __esm(() => {
729
765
  init_pg_migrations();
@@ -732,7 +768,9 @@ var init_migrate_command = __esm(() => {
732
768
  // src/server/cloud.ts
733
769
  var exports_cloud = {};
734
770
  __export(exports_cloud, {
771
+ runRecordedMigration: () => runRecordedMigration,
735
772
  resolveSigningSecret: () => resolveSigningSecret,
773
+ resolveMigrationDatabaseUrl: () => resolveMigrationDatabaseUrl,
736
774
  resolveDatabaseUrl: () => resolveDatabaseUrl,
737
775
  resolveDataBackend: () => resolveDataBackend,
738
776
  requireSigningSecret: () => requireSigningSecret,
@@ -740,6 +778,7 @@ __export(exports_cloud, {
740
778
  pingCloud: () => pingCloud,
741
779
  migrateCloudSchema: () => migrateCloudSchema,
742
780
  isPostgresBackendEnabled: () => isPostgresBackendEnabled,
781
+ getMigrationPg: () => getMigrationPg,
743
782
  getCloudVerifier: () => getCloudVerifier,
744
783
  getCloudPg: () => getCloudPg,
745
784
  getApiKeyStore: () => getApiKeyStore,
@@ -759,8 +798,7 @@ function getCloudPg() {
759
798
  cachedPg = new PgAdapterAsync(url);
760
799
  return cachedPg;
761
800
  }
762
- function authClient() {
763
- const pg2 = getCloudPg();
801
+ function authClient(pg2 = getCloudPg()) {
764
802
  return {
765
803
  async many(sql, params = []) {
766
804
  return await pg2.all(sql, ...params);
@@ -796,8 +834,30 @@ function getCloudVerifier() {
796
834
  });
797
835
  return cachedVerifier;
798
836
  }
799
- async function migrateCloudSchema() {
800
- await applyRecordedCloudMigrations(getCloudPg(), () => getApiKeyStore().ensureSchema());
837
+ function getMigrationPg() {
838
+ const url = resolveMigrationDatabaseUrl();
839
+ if (!url) {
840
+ throw new Error("migrate requires a database URL (HASNA_RECORDINGS_MIGRATE_DATABASE_URL / RECORDINGS_MIGRATE_DATABASE_URL / HASNA_RECORDINGS_DATABASE_URL / RECORDINGS_DATABASE_URL / DATABASE_URL).");
841
+ }
842
+ return new PgAdapterAsync(url);
843
+ }
844
+ async function migrateCloudSchema(pg2 = getCloudPg()) {
845
+ await applyRecordedCloudMigrations(pg2, () => new ApiKeyStore(authClient(pg2)).ensureSchema());
846
+ }
847
+ async function runRecordedMigration() {
848
+ const migrationPg = getMigrationPg();
849
+ const runtimeUrl = resolveDatabaseUrl();
850
+ const migrationUrl = resolveMigrationDatabaseUrl();
851
+ const separateRoles = runtimeUrl !== undefined && migrationUrl !== undefined && runtimeUrl !== migrationUrl;
852
+ await runCloudMigration({
853
+ pingConnectivity: () => pingCloudConnectivity2(migrationPg),
854
+ applyMigrations: async () => {
855
+ await migrateCloudSchema(migrationPg);
856
+ },
857
+ validateContract: () => assertCloudSchemaContract(migrationPg)
858
+ }, separateRoles ? async () => {
859
+ await assertCloudSchemaReady(getCloudPg());
860
+ } : undefined);
801
861
  }
802
862
  async function ensureCloudSchema() {
803
863
  if (schemaEnsured)
@@ -942,6 +1002,9 @@ function parseRecording(row) {
942
1002
  return {
943
1003
  id: row["id"],
944
1004
  audio_path: row["audio_path"] || null,
1005
+ audio_object_key: row["audio_object_key"] || null,
1006
+ audio_sha256: row["audio_sha256"] || null,
1007
+ audio_bytes: Number(row["audio_bytes"] ?? 0) || null,
945
1008
  raw_text: row["raw_text"],
946
1009
  processed_text: row["processed_text"] || null,
947
1010
  processing_mode: row["processing_mode"] || "raw",
@@ -1031,9 +1094,9 @@ async function createRecording(pg2, input, idempotencyKey, idempotencyContext) {
1031
1094
  throw new ProjectNotFoundError(input.project_id);
1032
1095
  resolvedProjectId = project.id;
1033
1096
  }
1034
- const insertResult = await transaction.run(`INSERT INTO recordings (id, audio_path, raw_text, processed_text, processing_mode, model_used, enhancement_model, duration_ms, language, tags, agent_id, project_id, session_id, goal, role, task_list_id, machine_id, metadata)
1035
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1036
- ON CONFLICT(id) DO NOTHING`, id, input.audio_path || null, input.raw_text, input.processed_text || null, input.processing_mode || "raw", input.model_used || "gpt-4o-transcribe", input.enhancement_model || null, input.duration_ms || 0, input.language || null, JSON.stringify(input.tags || []), resolvedAgentId, resolvedProjectId, input.session_id || null, input.goal || null, input.role || null, input.task_list_id || null, input.machine_id || null, JSON.stringify(input.metadata || {}));
1097
+ const insertResult = await transaction.run(`INSERT INTO recordings (id, audio_path, audio_object_key, audio_sha256, audio_bytes, raw_text, processed_text, processing_mode, model_used, enhancement_model, duration_ms, language, tags, agent_id, project_id, session_id, goal, role, task_list_id, machine_id, metadata)
1098
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1099
+ ON CONFLICT(id) DO NOTHING`, id, input.audio_path || null, input.audio_object_key || null, input.audio_sha256 || null, input.audio_bytes || null, input.raw_text, input.processed_text || null, input.processing_mode || "raw", input.model_used || "gpt-4o-transcribe", input.enhancement_model || null, input.duration_ms || 0, input.language || null, JSON.stringify(input.tags || []), resolvedAgentId, resolvedProjectId, input.session_id || null, input.goal || null, input.role || null, input.task_list_id || null, input.machine_id || null, JSON.stringify(input.metadata || {}));
1037
1100
  if (insertResult.changes === 0) {
1038
1101
  throw new IdempotencyConflictError;
1039
1102
  }
@@ -1454,6 +1517,9 @@ function buildV1OpenApiDocument(version = VERSION) {
1454
1517
  id: { type: "string" },
1455
1518
  raw_text: { type: "string" },
1456
1519
  audio_path: { type: "string" },
1520
+ audio_object_key: { type: "string" },
1521
+ audio_sha256: { type: "string" },
1522
+ audio_bytes: { type: "number" },
1457
1523
  processed_text: { type: "string" },
1458
1524
  processing_mode: { type: "string", enum: ["raw", "enhanced"] },
1459
1525
  model_used: { type: "string" },
@@ -1742,6 +1808,9 @@ var init_openapi = __esm(() => {
1742
1808
  properties: {
1743
1809
  id: { type: "string" },
1744
1810
  audio_path: { type: "string", nullable: true },
1811
+ audio_object_key: { type: "string", nullable: true },
1812
+ audio_sha256: { type: "string", nullable: true },
1813
+ audio_bytes: { type: "number", nullable: true },
1745
1814
  raw_text: { type: "string" },
1746
1815
  processed_text: { type: "string", nullable: true },
1747
1816
  processing_mode: { type: "string", enum: ["raw", "enhanced"] },
@@ -1786,6 +1855,172 @@ var init_openapi = __esm(() => {
1786
1855
  };
1787
1856
  });
1788
1857
 
1858
+ // src/server/client-ip.ts
1859
+ function normalizeIpLiteral(raw) {
1860
+ let value = (raw ?? "").trim();
1861
+ if (!value)
1862
+ return null;
1863
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(value);
1864
+ if (bracketed)
1865
+ value = bracketed[1];
1866
+ else if (/^\d{1,3}(?:\.\d{1,3}){3}:\d+$/.test(value))
1867
+ value = value.slice(0, value.lastIndexOf(":"));
1868
+ value = value.trim().toLowerCase();
1869
+ if (!value || value.length > 45)
1870
+ return null;
1871
+ const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(value);
1872
+ if (mapped)
1873
+ value = mapped[1];
1874
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(value);
1875
+ if (ipv4) {
1876
+ return ipv4.slice(1).every((octet) => Number(octet) <= 255 && !/^0\d/.test(octet)) ? value : null;
1877
+ }
1878
+ if (/^[0-9a-f:]+(?:\.\d{1,3}){0,3}$/.test(value) && value.includes(":") && !/:::/.test(value)) {
1879
+ return value;
1880
+ }
1881
+ return null;
1882
+ }
1883
+ function parseForwardedFor(header) {
1884
+ if (!header)
1885
+ return [];
1886
+ return header.split(",").map((entry) => normalizeIpLiteral(entry)).filter((entry) => entry !== null);
1887
+ }
1888
+ function parseCidr(entry) {
1889
+ const slash = entry.indexOf("/");
1890
+ const rawAddress = slash >= 0 ? entry.slice(0, slash) : entry;
1891
+ const rawPrefix = slash >= 0 ? entry.slice(slash + 1) : null;
1892
+ const address = normalizeIpLiteral(rawAddress);
1893
+ if (!address)
1894
+ return null;
1895
+ if (address.includes(":")) {
1896
+ if (rawPrefix !== null && !/^\d{1,3}$/.test(rawPrefix))
1897
+ return null;
1898
+ const prefix2 = rawPrefix === null ? 128 : Number(rawPrefix);
1899
+ if (prefix2 < 0 || prefix2 > 128)
1900
+ return null;
1901
+ const bytes2 = expandV6(address);
1902
+ if (!bytes2)
1903
+ return null;
1904
+ const masked2 = maskNetwork(bytes2, prefix2);
1905
+ return { address, prefix: prefix2, bytes: masked2, family: "ipv6" };
1906
+ }
1907
+ if (rawPrefix !== null && !/^\d{1,3}$/.test(rawPrefix))
1908
+ return null;
1909
+ const prefix = rawPrefix === null ? 32 : Number(rawPrefix);
1910
+ if (prefix < 0 || prefix > 32)
1911
+ return null;
1912
+ const bytes = address.split(".").map((octet) => Number(octet));
1913
+ const masked = maskNetwork(bytes, prefix);
1914
+ return { address, prefix, bytes: masked, family: "ipv4" };
1915
+ }
1916
+ function expandV6(address) {
1917
+ const compressed = address.split("::");
1918
+ if (compressed.length > 2)
1919
+ return null;
1920
+ let groups = [];
1921
+ if (compressed.length === 2) {
1922
+ const left = compressed[0] ? compressed[0].split(":") : [];
1923
+ const right = compressed[1] ? compressed[1].split(":") : [];
1924
+ const missing = 8 - left.length - right.length;
1925
+ if (missing < 1)
1926
+ return null;
1927
+ groups = [...left, ...Array(missing).fill("0"), ...right];
1928
+ } else {
1929
+ groups = address.split(":");
1930
+ }
1931
+ if (groups.length !== 8)
1932
+ return null;
1933
+ const bytes = [];
1934
+ for (const group of groups) {
1935
+ if (!/^[0-9a-f]{1,4}$/.test(group))
1936
+ return null;
1937
+ const value = Number.parseInt(group, 16);
1938
+ bytes.push(value >> 8 & 255, value & 255);
1939
+ }
1940
+ return bytes;
1941
+ }
1942
+ function maskNetwork(bytes, prefix) {
1943
+ const masked = bytes.slice();
1944
+ const fullBytes = Math.floor(prefix / 8);
1945
+ const remainder = prefix % 8;
1946
+ for (let i = 0;i < masked.length; i++) {
1947
+ if (i >= fullBytes) {
1948
+ if (i === fullBytes && remainder > 0) {
1949
+ masked[i] = masked[i] & (255 << 8 - remainder & 255);
1950
+ } else {
1951
+ masked[i] = 0;
1952
+ }
1953
+ }
1954
+ }
1955
+ return masked;
1956
+ }
1957
+ function parseTrustedProxies(raw) {
1958
+ if (!raw)
1959
+ return [];
1960
+ const seen = new Set;
1961
+ const trusted = [];
1962
+ for (const entry of raw.split(",").map((part) => part.trim()).filter(Boolean)) {
1963
+ const cidr = parseCidr(entry);
1964
+ if (cidr && !seen.has(entry)) {
1965
+ seen.add(entry);
1966
+ trusted.push(entry);
1967
+ }
1968
+ }
1969
+ return trusted;
1970
+ }
1971
+ function isMatch(candidateBytes, cidr) {
1972
+ if (cidr.bytes.length !== candidateBytes.length)
1973
+ return false;
1974
+ const masked = maskNetwork(candidateBytes, cidr.prefix);
1975
+ for (let i = 0;i < cidr.bytes.length; i++) {
1976
+ if (cidr.bytes[i] !== masked[i])
1977
+ return false;
1978
+ }
1979
+ return true;
1980
+ }
1981
+ function isTrustedAddress(ip, trusted) {
1982
+ const address = normalizeIpLiteral(ip);
1983
+ if (!address)
1984
+ return false;
1985
+ const cidrs = trusted.map(parseCidr).filter((cidr) => cidr !== null);
1986
+ for (const cidr of cidrs) {
1987
+ if (cidr.address === address)
1988
+ return true;
1989
+ const bytes = cidr.family === "ipv4" ? address.split(".").map((octet) => Number(octet)) : expandV6(address);
1990
+ if (bytes && isMatch(bytes, cidr))
1991
+ return true;
1992
+ }
1993
+ return false;
1994
+ }
1995
+ function resolveTrustProxy(env = process.env) {
1996
+ return TRUST_TRUE_VALUES.has((env[TRUST_PROXY_ENV] ?? "").trim().toLowerCase());
1997
+ }
1998
+ function trustedProxiesFromEnv(env = process.env) {
1999
+ return parseTrustedProxies(env[TRUSTED_PROXIES_ENV]);
2000
+ }
2001
+ function resolveRequestClientIp(input) {
2002
+ const socket = normalizeIpLiteral(input.socketAddress);
2003
+ if (input.trustProxy !== true)
2004
+ return socket;
2005
+ const trusted = input.trustedProxies ?? [];
2006
+ const real = normalizeIpLiteral(input.headers.get("x-real-ip"));
2007
+ if (real)
2008
+ return real;
2009
+ const chain = parseForwardedFor(input.headers.get("x-forwarded-for"));
2010
+ for (let i = chain.length - 1;i >= 0; i--) {
2011
+ const hop = chain[i];
2012
+ if (!isTrustedAddress(hop, trusted))
2013
+ return hop;
2014
+ }
2015
+ if (chain.length > 0)
2016
+ return chain[0];
2017
+ return socket;
2018
+ }
2019
+ var TRUST_PROXY_ENV = "RECORDINGS_TRUST_PROXY", TRUSTED_PROXIES_ENV = "RECORDINGS_TRUSTED_PROXIES", TRUST_TRUE_VALUES;
2020
+ var init_client_ip = __esm(() => {
2021
+ TRUST_TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
2022
+ });
2023
+
1789
2024
  // src/mcp/http.ts
1790
2025
  var exports_http = {};
1791
2026
  __export(exports_http, {
@@ -5888,32 +6123,6 @@ function assertMaintenanceAbsent(env) {
5888
6123
  if (pathExists(installMaintenanceMarkerPath(env)))
5889
6124
  throw maintenanceUnavailable();
5890
6125
  }
5891
- function canonicalPotentialPath(path) {
5892
- const absolutePath = resolve(path);
5893
- let existingPath = absolutePath;
5894
- const missingEntries = [];
5895
- while (true) {
5896
- try {
5897
- return join(realpathSync(existingPath), ...missingEntries);
5898
- } catch (error2) {
5899
- if (error2.code !== "ENOENT")
5900
- return absolutePath;
5901
- const parent = dirname(existingPath);
5902
- if (parent === existingPath)
5903
- return absolutePath;
5904
- missingEntries.unshift(basename(existingPath));
5905
- existingPath = parent;
5906
- }
5907
- }
5908
- }
5909
- function pathIsWithin(root, candidate) {
5910
- const relativePath = relative(root, candidate);
5911
- return relativePath === "" || relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath);
5912
- }
5913
- function isGlobalRecordingsStatePath(path, env = process.env) {
5914
- const globalStateRoot = join(stateParent(env), "recordings");
5915
- return pathIsWithin(resolve(globalStateRoot), resolve(path)) || pathIsWithin(canonicalPotentialPath(globalStateRoot), canonicalPotentialPath(path));
5916
- }
5917
6126
  function removeActiveLease(activeLease, releasedLease) {
5918
6127
  try {
5919
6128
  renameSync(activeLease, releasedLease);
@@ -5999,6 +6208,9 @@ function loadConfig(configPath) {
5999
6208
  if (process.env.RECORDINGS_API_KEY) {
6000
6209
  config.openai_api_key = process.env.RECORDINGS_API_KEY;
6001
6210
  }
6211
+ if (process.env.RECORDINGS_OPENAI_API_KEY) {
6212
+ config.openai_api_key = process.env.RECORDINGS_OPENAI_API_KEY;
6213
+ }
6002
6214
  if (process.env.RECORDINGS_ENHANCEMENT_KEY) {
6003
6215
  config.enhancement_api_key = process.env.RECORDINGS_ENHANCEMENT_KEY;
6004
6216
  }
@@ -6042,14 +6254,26 @@ function loadConfig(configPath) {
6042
6254
  if (process.env.RECORDINGS_AUDIO_DIR) {
6043
6255
  config.audio_dir = process.env.RECORDINGS_AUDIO_DIR;
6044
6256
  }
6257
+ if (process.env.HASNA_RECORDINGS_S3_BUCKET) {
6258
+ config.s3_bucket = process.env.HASNA_RECORDINGS_S3_BUCKET;
6259
+ } else if (process.env.RECORDINGS_S3_BUCKET) {
6260
+ config.s3_bucket = process.env.RECORDINGS_S3_BUCKET;
6261
+ }
6262
+ if (process.env.HASNA_RECORDINGS_S3_PREFIX) {
6263
+ config.s3_prefix = process.env.HASNA_RECORDINGS_S3_PREFIX;
6264
+ } else if (process.env.RECORDINGS_S3_PREFIX) {
6265
+ config.s3_prefix = process.env.RECORDINGS_S3_PREFIX;
6266
+ }
6267
+ if (process.env.RECORDINGS_S3_REGION) {
6268
+ config.s3_region = process.env.RECORDINGS_S3_REGION;
6269
+ } else if (process.env.AWS_REGION) {
6270
+ config.s3_region = process.env.AWS_REGION;
6271
+ }
6045
6272
  if (process.env.RECORDINGS_MAX_SECONDS) {
6046
6273
  config.max_recording_seconds = parseInt(process.env.RECORDINGS_MAX_SECONDS, 10);
6047
6274
  }
6048
- if (!config.openai_api_key) {
6049
- config.openai_api_key = loadSecretKey("OPENAI_API_KEY");
6050
- }
6051
6275
  if (!config.enhancement_api_key) {
6052
- config.enhancement_api_key = config.openai_api_key || loadSecretKey("OPENAI_API_KEY");
6276
+ config.enhancement_api_key = config.openai_api_key;
6053
6277
  }
6054
6278
  if (!explicitTranscriberModel) {
6055
6279
  config.transcriber_model = config.enhancement_model;
@@ -6229,66 +6453,9 @@ function mergeDirectoryContents(sourceDir, targetDir) {
6229
6453
  }
6230
6454
  }
6231
6455
  }
6232
- function loadSecretKey(keyName) {
6233
- const secretsPath = join2(getHomeDir(), ".secrets");
6234
- if (!existsSync(secretsPath))
6235
- return "";
6236
- for (const candidate of listSecretFiles(secretsPath)) {
6237
- try {
6238
- const content = readFileSync(candidate, "utf-8");
6239
- const match = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*"([^"]+)"`));
6240
- const doubleQuotedValue = match?.[1];
6241
- if (doubleQuotedValue !== undefined)
6242
- return doubleQuotedValue;
6243
- const match2 = content.match(new RegExp(`export\\s+${keyName}\\s*=\\s*'([^']+)'`));
6244
- const singleQuotedValue = match2?.[1];
6245
- if (singleQuotedValue !== undefined)
6246
- return singleQuotedValue;
6247
- const match3 = content.match(new RegExp(`${keyName}\\s*=\\s*(.+)`));
6248
- const unquotedValue = match3?.[1];
6249
- if (unquotedValue !== undefined)
6250
- return unquotedValue.trim().replace(/^["']|["']$/g, "");
6251
- } catch {}
6252
- }
6253
- return "";
6254
- }
6255
- function listSecretFiles(path) {
6256
- try {
6257
- const stats = statSync(path);
6258
- if (stats.isFile())
6259
- return [path];
6260
- if (!stats.isDirectory())
6261
- return [];
6262
- return readdirSync(path).sort().flatMap((entry) => {
6263
- const child = join2(path, entry);
6264
- try {
6265
- const childStats = statSync(child);
6266
- if (childStats.isDirectory())
6267
- return listSecretFiles(child);
6268
- if (childStats.isFile() && child.endsWith(".env"))
6269
- return [child];
6270
- } catch {}
6271
- return [];
6272
- });
6273
- } catch {
6274
- return [];
6275
- }
6276
- }
6277
6456
  function getHomeDir() {
6278
6457
  return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
6279
6458
  }
6280
- function ensureDataDir(config) {
6281
- const { mkdirSync: mkdirSync3 } = __require("fs");
6282
- const dbDir = config.db_path.substring(0, config.db_path.lastIndexOf("/"));
6283
- const releaseLease = [config.audio_dir, dbDir].some((path) => path.length > 0 && isGlobalRecordingsStatePath(path)) ? acquireLocalStoreReaderLease() : () => {};
6284
- try {
6285
- mkdirSync3(config.audio_dir, { recursive: true });
6286
- if (dbDir)
6287
- mkdirSync3(dbDir, { recursive: true });
6288
- } finally {
6289
- releaseLease();
6290
- }
6291
- }
6292
6459
  var POST_PROCESSING_MODES, DEFAULT_TRANSCRIPTION_MODEL = "gpt-4o-transcribe", DEFAULT_REALTIME_SESSION_MODEL = "gpt-realtime", DEFAULT_REALTIME_TRANSCRIPTION_MODEL = "gpt-realtime-whisper", DEFAULT_CONFIG;
6293
6460
  var init_config = __esm(() => {
6294
6461
  init_install_maintenance();
@@ -6330,6 +6497,9 @@ var init_config = __esm(() => {
6330
6497
  db_path: "",
6331
6498
  audio_dir: "",
6332
6499
  max_recording_seconds: 1800,
6500
+ s3_bucket: "",
6501
+ s3_prefix: "",
6502
+ s3_region: "",
6333
6503
  config_warnings: []
6334
6504
  };
6335
6505
  });
@@ -6421,6 +6591,9 @@ function repairSchemaDrift(db) {
6421
6591
  ensureColumn(db, "recordings", "task_list_id", "TEXT");
6422
6592
  ensureColumn(db, "recordings", "machine_id", "TEXT");
6423
6593
  ensureColumn(db, "recordings", "metadata", "TEXT DEFAULT '{}'");
6594
+ ensureColumn(db, "recordings", "audio_object_key", "TEXT");
6595
+ ensureColumn(db, "recordings", "audio_sha256", "TEXT");
6596
+ ensureColumn(db, "recordings", "audio_bytes", "INTEGER");
6424
6597
  ensureColumn(db, "agents", "active_project_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
6425
6598
  }
6426
6599
  function ensureColumn(db, table, column, definition) {
@@ -6514,6 +6687,11 @@ var init_database = __esm(() => {
6514
6687
  `,
6515
6688
  `
6516
6689
  ALTER TABLE agents ADD COLUMN active_project_id TEXT REFERENCES projects(id) ON DELETE SET NULL;
6690
+ `,
6691
+ `
6692
+ ALTER TABLE recordings ADD COLUMN audio_object_key TEXT;
6693
+ ALTER TABLE recordings ADD COLUMN audio_sha256 TEXT;
6694
+ ALTER TABLE recordings ADD COLUMN audio_bytes INTEGER;
6517
6695
  `
6518
6696
  ];
6519
6697
  CURRENT_MIGRATION_LEVEL = MIGRATIONS.length - 1;
@@ -6524,6 +6702,9 @@ function parseRow(row) {
6524
6702
  return {
6525
6703
  id: row["id"],
6526
6704
  audio_path: row["audio_path"] || null,
6705
+ audio_object_key: row["audio_object_key"] || null,
6706
+ audio_sha256: row["audio_sha256"] || null,
6707
+ audio_bytes: Number(row["audio_bytes"] ?? 0) || null,
6527
6708
  raw_text: row["raw_text"],
6528
6709
  processed_text: row["processed_text"] || null,
6529
6710
  processing_mode: row["processing_mode"] || "raw",
@@ -6555,9 +6736,9 @@ function createRecording2(input, db, idempotencyKey) {
6555
6736
  }
6556
6737
  const tagsJson = JSON.stringify(input.tags || []);
6557
6738
  const metadataJson = JSON.stringify(input.metadata || {});
6558
- const insertResult = d.query(`INSERT INTO recordings (id, audio_path, raw_text, processed_text, processing_mode, model_used, enhancement_model, duration_ms, language, tags, agent_id, project_id, session_id, goal, role, task_list_id, machine_id, metadata)
6559
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
6560
- ON CONFLICT(id) DO NOTHING`).run(id, input.audio_path || null, input.raw_text, input.processed_text || null, input.processing_mode || "raw", input.model_used || "gpt-4o-transcribe", input.enhancement_model || null, input.duration_ms || 0, input.language || null, tagsJson, input.agent_id || null, input.project_id || null, input.session_id || null, input.goal || null, input.role || null, input.task_list_id || null, input.machine_id || null, metadataJson);
6739
+ const insertResult = d.query(`INSERT INTO recordings (id, audio_path, audio_object_key, audio_sha256, audio_bytes, raw_text, processed_text, processing_mode, model_used, enhancement_model, duration_ms, language, tags, agent_id, project_id, session_id, goal, role, task_list_id, machine_id, metadata)
6740
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
6741
+ ON CONFLICT(id) DO NOTHING`).run(id, input.audio_path || null, input.audio_object_key || null, input.audio_sha256 || null, input.audio_bytes || null, input.raw_text, input.processed_text || null, input.processing_mode || "raw", input.model_used || "gpt-4o-transcribe", input.enhancement_model || null, input.duration_ms || 0, input.language || null, tagsJson, input.agent_id || null, input.project_id || null, input.session_id || null, input.goal || null, input.role || null, input.task_list_id || null, input.machine_id || null, metadataJson);
6561
6742
  if (insertResult.changes === 0) {
6562
6743
  const existing = getRecording2(id, d);
6563
6744
  if (existing?.id === id)
@@ -6574,6 +6755,13 @@ function createRecording2(input, db, idempotencyKey) {
6574
6755
  });
6575
6756
  return create();
6576
6757
  }
6758
+ function updateRecordingAudio(id, audioObjectKey, audioSha256, audioBytes, db) {
6759
+ const d = db || getDatabase();
6760
+ const result = d.query(`UPDATE recordings SET audio_object_key = ?, audio_sha256 = ?, audio_bytes = ? WHERE id = ?`).run(audioObjectKey, audioSha256, audioBytes, id);
6761
+ if (result.changes === 0)
6762
+ return null;
6763
+ return getRecording2(id, d);
6764
+ }
6577
6765
  function getRecording2(id, db) {
6578
6766
  const d = db || getDatabase();
6579
6767
  let row = d.query("SELECT * FROM recordings WHERE id = ?").get(id);
@@ -6795,151 +6983,266 @@ var init_feedback = __esm(() => {
6795
6983
  init_version();
6796
6984
  });
6797
6985
 
6986
+ // src/lib/local-opt-in.ts
6987
+ import {
6988
+ clientTransportEnvKeys,
6989
+ credentialOverrideEnvKey,
6990
+ credentialPointerEnvKey,
6991
+ CREDENTIAL_PROFILE_ENV_KEY
6992
+ } from "@hasna/contracts/client";
6993
+ function isRecordingsLocalOptIn(env = process.env) {
6994
+ return RECORDINGS_LOCAL_OPT_IN_ENV_KEYS.some((key) => (env[key] ?? "").trim() !== "");
6995
+ }
6996
+ function recordingsAuthorityEnvKeys() {
6997
+ const keys = clientTransportEnvKeys("recordings");
6998
+ return [
6999
+ ...keys.apiUrlKeys,
7000
+ ...keys.apiKeyKeys,
7001
+ credentialOverrideEnvKey("recordings"),
7002
+ credentialPointerEnvKey("recordings"),
7003
+ CREDENTIAL_PROFILE_ENV_KEY
7004
+ ].filter((key) => !RECORDINGS_CARVED_LEGACY_ENV_KEYS.includes(key));
7005
+ }
7006
+ function hasRecordingsEnvAuthorityIntent(env = process.env) {
7007
+ return recordingsAuthorityEnvKeys().some((key) => (env[key] ?? "").trim() !== "");
7008
+ }
7009
+ function selectsRecordingsLocalStore(env = process.env) {
7010
+ return !hasRecordingsEnvAuthorityIntent(env) && isRecordingsLocalOptIn(env);
7011
+ }
7012
+ function recordingsResolverEnv(env) {
7013
+ const carved = RECORDINGS_CARVED_LEGACY_ENV_KEYS.filter((key) => (key in env));
7014
+ const blanks = recordingsAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
7015
+ const removed = new Set([...carved, ...blanks]);
7016
+ if (removed.size === 0)
7017
+ return env;
7018
+ const next = { ...env };
7019
+ for (const key of removed)
7020
+ delete next[key];
7021
+ return next;
7022
+ }
7023
+ function isAmbientRecordingsEnv(env) {
7024
+ if (typeof process !== "undefined" && env === process.env) {
7025
+ return true;
7026
+ }
7027
+ return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
7028
+ }
7029
+ function recordingsResolverInputs(env, credentials = {}) {
7030
+ const normalised = recordingsResolverEnv(env);
7031
+ if (normalised === env)
7032
+ return { env: normalised, credentials };
7033
+ const keychain = { ...credentials.keychain };
7034
+ if (keychain.enabled === undefined && keychain.run === undefined) {
7035
+ keychain.enabled = isAmbientRecordingsEnv(env);
7036
+ }
7037
+ return { env: normalised, credentials: { ...credentials, keychain } };
7038
+ }
7039
+ var RECORDINGS_LOCAL_OPT_IN_ENV_KEYS, RECORDINGS_CARVED_LEGACY_ENV_KEYS, CONTRACTS_AMBIENT_ENVIRONMENT;
7040
+ var init_local_opt_in = __esm(() => {
7041
+ RECORDINGS_LOCAL_OPT_IN_ENV_KEYS = [
7042
+ "HASNA_RECORDINGS_LOCAL",
7043
+ "RECORDINGS_LOCAL"
7044
+ ];
7045
+ RECORDINGS_CARVED_LEGACY_ENV_KEYS = [
7046
+ "RECORDINGS_API_URL",
7047
+ "RECORDINGS_API_KEY"
7048
+ ];
7049
+ CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
7050
+ });
7051
+
6798
7052
  // src/http/client.ts
6799
- import { createClientTransport } from "@hasna/contracts/client";
6800
7053
  import {
6801
- createHasnaStorageClient,
7054
+ clientTransportEnvKeys as clientTransportEnvKeys2,
7055
+ resolveClientTransport
7056
+ } from "@hasna/contracts/client";
7057
+ import {
6802
7058
  resolveStorageClient
6803
7059
  } from "@hasna/contracts/client/storage";
6804
- function envToken(name) {
6805
- return name.toUpperCase().replace(/-/g, "_");
6806
- }
6807
- function envKeys(name) {
6808
- const token = envToken(name);
6809
- return {
6810
- storeKeys: [`HASNA_${token}_CLIENT_STORE`, `${token}_CLIENT_STORE`],
6811
- apiUrlKeys: [`HASNA_${token}_API_URL`],
6812
- apiKeyKeys: [`HASNA_${token}_API_KEY`]
6813
- };
6814
- }
6815
- function normalizeClientStore(value) {
6816
- const normalized = value.trim().toLowerCase();
6817
- if (normalized === "sqlite")
6818
- return "sqlite";
6819
- if (normalized === "http" || normalized === "https")
6820
- return "http";
6821
- throw new Error(`Unknown client store: ${value}. Use sqlite or http.`);
6822
- }
6823
- function firstEnv(env, keys) {
6824
- for (const key of keys) {
6825
- const value = env[key]?.trim();
6826
- if (value)
6827
- return { key, value };
7060
+ function throwRecordingsAuthorityFailure(error2) {
7061
+ const message = error2 instanceof Error ? error2.message : String(error2);
7062
+ const name = error2 instanceof Error ? error2.name : "";
7063
+ if (name === "CredentialResolutionError" || name === "CredentialFileUnsafeError") {
7064
+ throw new Error(`REMOTE_API_CREDENTIAL_INVALID: ${message} There is no local fallback: ` + "local SQLite is opt-in only (HASNA_RECORDINGS_LOCAL=1) and is disabled by default \u2014 failing closed", { cause: error2 });
6828
7065
  }
6829
- return null;
7066
+ if (/no API key could be resolved/.test(message)) {
7067
+ if (/is not set and no API key could be resolved/.test(message)) {
7068
+ throw new Error("REMOTE_API_CONFIG_MISSING: no Recordings credential resolved from the Keychain item " + `hasna.credentials.recordings.api-key, ~/.hasna/recordings/config/credentials, or HASNA_RECORDINGS_API_KEY. ${message} ` + "There is no local fallback: local SQLite is opt-in only (HASNA_RECORDINGS_LOCAL=1, alias RECORDINGS_LOCAL=1) " + "and is disabled by default \u2014 failing closed instead of serving the local store", { cause: error2 });
7069
+ }
7070
+ throw new Error("REMOTE_API_KEY_MISSING: remote Recordings storage requires HASNA_RECORDINGS_API_KEY, the Keychain item " + `hasna.credentials.recordings.api-key, or ~/.hasna/recordings/config/credentials. ${message} ` + "There is no local fallback: local SQLite is opt-in only (HASNA_RECORDINGS_LOCAL=1) and is disabled by default \u2014 failing closed", { cause: error2 });
7071
+ }
7072
+ throw new Error(`REMOTE_API_URL_INVALID: ${message} local SQLite fallback is disabled`, { cause: error2 });
6830
7073
  }
6831
- function toV1BaseUrl(apiUrl) {
6832
- const url = new URL(apiUrl);
6833
- if (url.protocol !== "http:" && url.protocol !== "https:") {
6834
- throw new Error("API URL must use http or https.");
6835
- }
6836
- let path = url.pathname.replace(/\/+$/, "");
6837
- if (path.endsWith("/v1"))
6838
- path = path.slice(0, -"/v1".length);
6839
- url.pathname = `${path}/v1`;
6840
- url.search = "";
6841
- url.hash = "";
6842
- return url.toString().replace(/\/+$/, "");
6843
- }
6844
- function resolveTransport(name, env = process.env) {
6845
- const keys = envKeys(name);
6846
- const storeHit = firstEnv(env, keys.storeKeys);
6847
- const urlHit = firstEnv(env, keys.apiUrlKeys);
6848
- const keyHit = firstEnv(env, keys.apiKeyKeys);
6849
- let requested = "sqlite";
6850
- let modeSource = "default";
6851
- if (storeHit) {
6852
- requested = normalizeClientStore(storeHit.value);
6853
- modeSource = storeHit.key;
6854
- } else if (urlHit && keyHit) {
6855
- requested = "http";
6856
- modeSource = "auto:api-url+api-key";
6857
- } else if (urlHit || keyHit) {
6858
- const missing = urlHit ? keys.apiKeyKeys[0] : keys.apiUrlKeys[0];
6859
- const present = urlHit ? keys.apiUrlKeys[0] : keys.apiKeyKeys[0];
6860
- return {
6861
- transport: "sqlite",
6862
- requested,
6863
- modeSource,
6864
- baseUrl: null,
6865
- apiKeyPresent: Boolean(keyHit),
6866
- misconfigured: true,
6867
- warning: `${present} is set but ${missing} is not: the hosted API is only ` + `selected when BOTH are present. Set ${missing}, or unset ${present} to ` + `use the on-box store.`
6868
- };
7074
+ function resolveRecordingsCloudClient(env = process.env, options = {}) {
7075
+ if (selectsRecordingsLocalStore(env))
7076
+ return null;
7077
+ const resolverInputs = recordingsResolverInputs(env, options.credentials);
7078
+ let resolved;
7079
+ try {
7080
+ resolved = resolveStorageClient("recordings", resolverInputs.env, {
7081
+ fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" }),
7082
+ credentials: resolverInputs.credentials
7083
+ });
7084
+ } catch (error2) {
7085
+ throwRecordingsAuthorityFailure(error2);
6869
7086
  }
6870
- if (requested === "sqlite") {
6871
- return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
7087
+ const client = resolved.client;
7088
+ return client;
7089
+ }
7090
+ var RETRY_STATUSES, IDEMPOTENT;
7091
+ var init_client = __esm(() => {
7092
+ init_local_opt_in();
7093
+ RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
7094
+ IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
7095
+ });
7096
+
7097
+ // src/lib/audio-artifact-storage.ts
7098
+ import { GetObjectCommand, PutObjectCommand, S3Client as AwsS3Client } from "@aws-sdk/client-s3";
7099
+ import { createHash as createHash2 } from "crypto";
7100
+ import { readFile } from "fs/promises";
7101
+ import { extname } from "path";
7102
+ function contentTypeForExtension(extension) {
7103
+ return AUDIO_CONTENT_TYPES[extension.toLowerCase()] ?? "application/octet-stream";
7104
+ }
7105
+
7106
+ class AudioArtifactStorage {
7107
+ bucket;
7108
+ prefix;
7109
+ s3;
7110
+ constructor(options = {}) {
7111
+ this.bucket = options.bucket;
7112
+ this.prefix = (options.prefix || DEFAULT_S3_PREFIX).replace(/^\/+|\/+$/g, "");
7113
+ this.s3 = this.bucket ? options.client ?? new AwsS3Client({ region: options.region || process.env.AWS_REGION || "us-east-1" }) : undefined;
7114
+ }
7115
+ get usesS3() {
7116
+ return Boolean(this.bucket);
7117
+ }
7118
+ objectKeyFor(recordingId, sha256, extension) {
7119
+ assertSha256(sha256);
7120
+ const safeExtension = extension.toLowerCase().match(/^[a-z0-9]+/)?.[0] || "bin";
7121
+ return `${this.prefix}/${recordingId}/${sha256}.${safeExtension}`;
7122
+ }
7123
+ async uploadAudio(recordingId, audioPath) {
7124
+ if (!this.bucket || !this.s3)
7125
+ return null;
7126
+ const bytes = await readFileBytes(audioPath);
7127
+ if (!bytes)
7128
+ return null;
7129
+ const sha256 = sha256Hex(bytes);
7130
+ const extension = extensionOf(audioPath);
7131
+ const key = this.objectKeyFor(recordingId, sha256, extension);
7132
+ await this.s3.send(new PutObjectCommand({
7133
+ Bucket: this.bucket,
7134
+ Key: key,
7135
+ Body: bytes,
7136
+ ContentType: contentTypeForExtension(extension)
7137
+ }));
7138
+ return { objectKey: key, sha256, bytes: bytes.byteLength };
7139
+ }
7140
+ async readAudio(objectKey) {
7141
+ if (!this.bucket || !this.s3)
7142
+ return null;
7143
+ try {
7144
+ const response = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }));
7145
+ return await bodyToBytes(response.Body);
7146
+ } catch {
7147
+ return null;
7148
+ }
6872
7149
  }
6873
- if (!urlHit) {
6874
- return {
6875
- transport: "sqlite",
6876
- requested,
6877
- modeSource,
6878
- baseUrl: null,
6879
- apiKeyPresent: Boolean(keyHit),
6880
- misconfigured: true,
6881
- warning: `${modeSource}=http but no API URL is set (${keys.apiUrlKeys[0]}). Refusing to route to the API.`
6882
- };
7150
+ }
7151
+ function resolveAudioArtifactStorage(env = process.env, client) {
7152
+ const bucket = env.HASNA_RECORDINGS_S3_BUCKET || env.RECORDINGS_S3_BUCKET || undefined;
7153
+ const prefix = env.HASNA_RECORDINGS_S3_PREFIX || env.RECORDINGS_S3_PREFIX || undefined;
7154
+ const region = env.RECORDINGS_S3_REGION || env.AWS_REGION || undefined;
7155
+ return new AudioArtifactStorage({ bucket, prefix, region, client });
7156
+ }
7157
+ async function uploadAudioAtCreation(recordingId, audioPath, storage = resolveAudioArtifactStorage()) {
7158
+ if (!audioPath)
7159
+ return null;
7160
+ if (!storage.usesS3)
7161
+ return null;
7162
+ try {
7163
+ const uploaded = await storage.uploadAudio(recordingId, audioPath);
7164
+ if (!uploaded)
7165
+ return null;
7166
+ console.warn(`recordings: uploaded audio for ${recordingId} to ${uploaded.objectKey}`);
7167
+ return uploaded;
7168
+ } catch (error2) {
7169
+ console.warn(`recordings: audio upload failed for ${recordingId} (${String(error2 instanceof Error ? error2.message : error2)}); keeping local audio_path only`);
7170
+ return null;
6883
7171
  }
6884
- if (!keyHit) {
6885
- return {
6886
- transport: "sqlite",
6887
- requested,
6888
- modeSource,
6889
- baseUrl: null,
6890
- apiKeyPresent: false,
6891
- misconfigured: true,
6892
- warning: `${modeSource}=http but no API key is set (${keys.apiKeyKeys[0]}). Refusing to route to the API.`
6893
- };
7172
+ }
7173
+ function assertSha256(value) {
7174
+ if (!SHA256_HEX.test(value)) {
7175
+ throw new Error("audio digest must be a lowercase hex sha-256; refusing to build a storage key from it");
6894
7176
  }
6895
- const rawUrl = urlHit.value;
6896
- let baseUrl;
7177
+ }
7178
+ function sha256Hex(bytes) {
7179
+ const hash = createHash2("sha256");
7180
+ hash.update(bytes);
7181
+ return hash.digest("hex");
7182
+ }
7183
+ async function readFileBytes(path) {
6897
7184
  try {
6898
- baseUrl = toV1BaseUrl(rawUrl);
6899
- } catch (error2) {
6900
- const message = error2 instanceof Error ? error2.message : String(error2);
6901
- return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: true, misconfigured: true, warning: `Invalid API URL: ${message}.` };
7185
+ return new Uint8Array(await readFile(path));
7186
+ } catch {
7187
+ return null;
6902
7188
  }
6903
- return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
6904
7189
  }
6905
- function resolveStoreClient(name, env = process.env) {
6906
- const resolution = resolveTransport(name, env);
6907
- if (resolution.misconfigured) {
6908
- const wired2 = createClientTransport(name, env);
6909
- if (wired2.transport === "http") {
6910
- return {
6911
- transport: "http",
6912
- client: createHasnaStorageClient(name, wired2.client),
6913
- resolution: {
6914
- transport: "http",
6915
- requested: "http",
6916
- modeSource: resolution.modeSource === "default" ? "auto:api-url+seam-credential" : resolution.modeSource,
6917
- baseUrl: wired2.resolution.baseUrl,
6918
- apiKeyPresent: true,
6919
- misconfigured: false,
6920
- warning: null
6921
- }
6922
- };
6923
- }
6924
- throw new Error(resolution.warning ?? `Client for '${name}' is misconfigured for the /v1 API.`);
7190
+ function extensionOf(path) {
7191
+ const extension = extname(path).replace(/^\./, "").toLowerCase();
7192
+ return extension || "bin";
7193
+ }
7194
+ async function bodyToBytes(body) {
7195
+ if (!body)
7196
+ return new Uint8Array(0);
7197
+ if (body instanceof Uint8Array)
7198
+ return body;
7199
+ if (typeof body.transformToByteArray === "function") {
7200
+ return new Uint8Array(await body.transformToByteArray());
6925
7201
  }
6926
- if (resolution.transport === "sqlite" || !resolution.baseUrl) {
6927
- return { transport: "sqlite", client: null, resolution };
7202
+ if (typeof body.getReader === "function") {
7203
+ const reader = body.getReader();
7204
+ const chunks2 = [];
7205
+ for (;; ) {
7206
+ const { value, done } = await reader.read();
7207
+ if (done)
7208
+ break;
7209
+ if (value)
7210
+ chunks2.push(value);
7211
+ }
7212
+ return concat(chunks2);
6928
7213
  }
6929
- const wired = createClientTransport(name, env);
6930
- if (wired.transport !== "http") {
6931
- throw new Error(`Client for '${name}' resolved to the /v1 API without an API key.`);
7214
+ const chunks = [];
7215
+ for await (const chunk of body) {
7216
+ chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
6932
7217
  }
6933
- return { transport: "http", client: createHasnaStorageClient(name, wired.client), resolution };
7218
+ return concat(chunks);
6934
7219
  }
6935
- var RETRY_STATUSES, IDEMPOTENT;
6936
- var init_client = __esm(() => {
6937
- RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
6938
- IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
7220
+ function concat(chunks) {
7221
+ const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
7222
+ const merged = new Uint8Array(new ArrayBuffer(total));
7223
+ let offset = 0;
7224
+ for (const chunk of chunks) {
7225
+ merged.set(chunk, offset);
7226
+ offset += chunk.byteLength;
7227
+ }
7228
+ return merged;
7229
+ }
7230
+ var SHA256_HEX, DEFAULT_S3_PREFIX = "recordings", AUDIO_CONTENT_TYPES;
7231
+ var init_audio_artifact_storage = __esm(() => {
7232
+ SHA256_HEX = /^[0-9a-f]{64}$/;
7233
+ AUDIO_CONTENT_TYPES = {
7234
+ wav: "audio/wav",
7235
+ mp3: "audio/mpeg",
7236
+ m4a: "audio/mp4",
7237
+ aac: "audio/aac",
7238
+ webm: "audio/webm",
7239
+ ogg: "audio/ogg",
7240
+ flac: "audio/flac"
7241
+ };
6939
7242
  });
6940
7243
 
6941
7244
  // src/store.ts
6942
- import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
7245
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
6943
7246
  function listQuery(filter) {
6944
7247
  if (!filter)
6945
7248
  return {};
@@ -7111,7 +7414,7 @@ function apiStore(client) {
7111
7414
  };
7112
7415
  }
7113
7416
  function recordingPageFingerprint(items) {
7114
- const hash = createHash2("sha256");
7417
+ const hash = createHash3("sha256");
7115
7418
  const ids = items.map((item) => String(item.id ?? "")).sort();
7116
7419
  for (const id of ids) {
7117
7420
  hash.update(String(id.length));
@@ -7146,16 +7449,19 @@ async function countStoreRecordings(store, filter) {
7146
7449
  }
7147
7450
  throw new Error(`Legacy Store exceeded ${maxPageRequests} pages while counting recordings`);
7148
7451
  }
7149
- function getStore(env = process.env) {
7150
- if (env === process.env && cached)
7452
+ function localArtifactStorageFor() {
7453
+ return localArtifactStorageOverride ?? resolveAudioArtifactStorage();
7454
+ }
7455
+ function getStore(env = process.env, options = {}) {
7456
+ if (env === process.env && cached && options.credentials === undefined)
7151
7457
  return cached;
7152
- const resolved = resolveStoreClient(APP, env);
7153
- const store = resolved.transport === "http" ? apiStore(resolved.client) : localStore;
7154
- if (env === process.env)
7458
+ const client = resolveRecordingsCloudClient(env, options);
7459
+ const store = client ? apiStore(client) : localStore;
7460
+ if (env === process.env && options.credentials === undefined)
7155
7461
  cached = store;
7156
7462
  return store;
7157
7463
  }
7158
- var APP = "recordings", localStore, cached = null;
7464
+ var localStore, cached = null, localArtifactStorageOverride;
7159
7465
  var init_store = __esm(() => {
7160
7466
  init_recordings();
7161
7467
  init_agents();
@@ -7164,11 +7470,20 @@ var init_store = __esm(() => {
7164
7470
  init_client();
7165
7471
  init_recording_create_identity();
7166
7472
  init_install_maintenance();
7473
+ init_audio_artifact_storage();
7167
7474
  localStore = {
7168
7475
  mode: "sqlite",
7169
7476
  baseUrl: null,
7170
7477
  async createRecording(input, idempotencyKey) {
7171
- return withLocalStoreReaderLease(() => createRecording2(input, undefined, idempotencyKey));
7478
+ return withLocalStoreReaderLease(async () => {
7479
+ const recording = createRecording2(input, undefined, idempotencyKey);
7480
+ if (!recording.audio_path)
7481
+ return recording;
7482
+ const uploaded = await uploadAudioAtCreation(recording.id, recording.audio_path, localArtifactStorageFor());
7483
+ if (!uploaded)
7484
+ return recording;
7485
+ return updateRecordingAudio(recording.id, uploaded.objectKey, uploaded.sha256, uploaded.bytes) ?? recording;
7486
+ });
7172
7487
  },
7173
7488
  async getRecording(id) {
7174
7489
  return withLocalStoreReaderLease(() => getRecording2(id));
@@ -7240,7 +7555,7 @@ import OpenAI from "openai";
7240
7555
  import { createReadStream } from "fs";
7241
7556
  function getClient(config) {
7242
7557
  if (!config.openai_api_key) {
7243
- throw new TranscriptionError("OpenAI API key not configured. Set OPENAI_API_KEY env var or add to ~/.secrets");
7558
+ throw new TranscriptionError("OpenAI API key not configured. Set OPENAI_API_KEY env var (e.g. " + "'secrets exec <key> --as OPENAI_API_KEY -- recordings \u2026')");
7244
7559
  }
7245
7560
  if (_client && _clientApiKey === config.openai_api_key)
7246
7561
  return _client;
@@ -8153,8 +8468,9 @@ var init_mcp = __esm(async () => {
8153
8468
  init_enhancer();
8154
8469
  init_version();
8155
8470
  init_machine();
8471
+ init_local_opt_in();
8472
+ init_client();
8156
8473
  config = loadConfig();
8157
- ensureDataDir(config);
8158
8474
  if (false) {}
8159
8475
  });
8160
8476
 
@@ -8172,13 +8488,12 @@ function jsonResponse(data, status = 200, headers) {
8172
8488
  });
8173
8489
  }
8174
8490
  function resolveClientIp(req, server) {
8175
- const trustProxy = process.env["RECORDINGS_TRUST_PROXY"] === "1" || process.env["RECORDINGS_TRUST_PROXY"] === "true";
8176
- if (trustProxy) {
8177
- const forwarded = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip")?.trim();
8178
- if (forwarded)
8179
- return forwarded;
8180
- }
8181
- return server.requestIP(req)?.address || "unknown";
8491
+ return resolveRequestClientIp({
8492
+ headers: req.headers,
8493
+ socketAddress: server.requestIP(req)?.address,
8494
+ trustProxy: resolveTrustProxy(),
8495
+ trustedProxies: trustedProxiesFromEnv()
8496
+ }) ?? "unknown";
8182
8497
  }
8183
8498
  function checkRateLimit(ip) {
8184
8499
  const now = Date.now();
@@ -8297,6 +8612,7 @@ var init_serve = __esm(() => {
8297
8612
  init_version();
8298
8613
  init_v1();
8299
8614
  init_openapi();
8615
+ init_client_ip();
8300
8616
  SECURITY_HEADERS = {
8301
8617
  "X-Content-Type-Options": "nosniff",
8302
8618
  "X-Frame-Options": "DENY",
@@ -8345,32 +8661,19 @@ Commands:
8345
8661
  migrate Apply the PostgreSQL schema, then exit. Idempotent.
8346
8662
 
8347
8663
  Environment:
8348
- HASNA_RECORDINGS_DATABASE_URL PostgreSQL DSN (present -> postgresql backend, else sqlite)
8349
- HASNA_RECORDINGS_API_SIGNING_KEY HMAC signing secret for API-key auth`);
8664
+ HASNA_RECORDINGS_DATABASE_URL PostgreSQL DSN (present -> postgresql backend, else sqlite)
8665
+ HASNA_RECORDINGS_MIGRATE_DATABASE_URL Optional MIGRATION-role DSN for migrate (two-role deploy;
8666
+ falls back to the runtime DSN when unset)
8667
+ HASNA_RECORDINGS_API_SIGNING_KEY HMAC signing secret for API-key auth`);
8350
8668
  }
8351
8669
  async function runMigrate() {
8352
- const {
8353
- closeCloud: closeCloud2,
8354
- getCloudPg: getCloudPg2,
8355
- migrateCloudSchema: migrateCloudSchema2,
8356
- pingCloudConnectivity: pingCloudConnectivity3,
8357
- resolveDatabaseUrl: resolveDatabaseUrl2
8358
- } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
8359
- const { assertCloudSchemaContract: assertCloudSchemaContract2 } = await Promise.resolve().then(() => (init_cloud_readiness(), exports_cloud_readiness));
8360
- const { runCloudMigration: runCloudMigration2 } = await Promise.resolve().then(() => (init_migrate_command(), exports_migrate_command));
8361
- if (!resolveDatabaseUrl2()) {
8362
- console.error("migrate: no database URL (HASNA_RECORDINGS_DATABASE_URL / RECORDINGS_DATABASE_URL / DATABASE_URL)");
8670
+ const { closeCloud: closeCloud2, resolveMigrationDatabaseUrl: resolveMigrationDatabaseUrl2, runRecordedMigration: runRecordedMigration2 } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
8671
+ if (!resolveMigrationDatabaseUrl2()) {
8672
+ console.error("migrate: no database URL (HASNA_RECORDINGS_MIGRATE_DATABASE_URL / RECORDINGS_MIGRATE_DATABASE_URL / HASNA_RECORDINGS_DATABASE_URL / RECORDINGS_DATABASE_URL / DATABASE_URL)");
8363
8673
  process.exit(2);
8364
8674
  }
8365
8675
  console.log("migrate: connecting\u2026");
8366
- await runCloudMigration2({
8367
- pingConnectivity: pingCloudConnectivity3,
8368
- applyMigrations: async () => {
8369
- console.log("migrate: applying schema (recordings tables + api_keys)\u2026");
8370
- await migrateCloudSchema2();
8371
- },
8372
- validateContract: () => assertCloudSchemaContract2(getCloudPg2())
8373
- });
8676
+ await runRecordedMigration2();
8374
8677
  console.log("migrate: done");
8375
8678
  await closeCloud2();
8376
8679
  process.exit(0);