@hasna/recordings 0.3.13 → 0.4.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 (105) hide show
  1. package/README.md +223 -37
  2. package/bun.lock +37 -38
  3. package/dist/cli/index.js +1884 -615
  4. package/dist/cli/macos-shortcut.d.ts +2 -2
  5. package/dist/db/database.d.ts.map +1 -1
  6. package/dist/db/pg-migrations.d.ts.map +1 -1
  7. package/dist/db/recordings.d.ts +6 -0
  8. package/dist/db/recordings.d.ts.map +1 -1
  9. package/dist/http/client.d.ts +183 -22
  10. package/dist/http/client.d.ts.map +1 -1
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2858 -2288
  14. package/dist/lib/audio-artifact-storage.d.ts +98 -0
  15. package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
  16. package/dist/lib/capture-probe.d.ts +3 -3
  17. package/dist/lib/capture-probe.d.ts.map +1 -1
  18. package/dist/lib/config.d.ts.map +1 -1
  19. package/dist/lib/local-opt-in.d.ts +132 -0
  20. package/dist/lib/local-opt-in.d.ts.map +1 -0
  21. package/dist/lib/macos-bundle.d.ts +1 -1
  22. package/dist/lib/persistence-probe.d.ts +56 -17
  23. package/dist/lib/persistence-probe.d.ts.map +1 -1
  24. package/dist/lib/transcriber.d.ts.map +1 -1
  25. package/dist/mcp/index.d.ts.map +1 -1
  26. package/dist/mcp/index.js +1689 -456
  27. package/dist/sdk/index.d.ts +13 -5
  28. package/dist/sdk/index.d.ts.map +1 -1
  29. package/dist/sdk/index.js +904 -1
  30. package/dist/sdk/resolve.d.ts +53 -0
  31. package/dist/sdk/resolve.d.ts.map +1 -0
  32. package/dist/sdk/v1.generated.d.ts +6 -0
  33. package/dist/sdk/v1.generated.d.ts.map +1 -1
  34. package/dist/server/client-ip.d.ts +39 -0
  35. package/dist/server/client-ip.d.ts.map +1 -0
  36. package/dist/server/cloud-config.d.ts +12 -0
  37. package/dist/server/cloud-config.d.ts.map +1 -1
  38. package/dist/server/cloud-readiness.d.ts.map +1 -1
  39. package/dist/server/cloud.d.ts +20 -2
  40. package/dist/server/cloud.d.ts.map +1 -1
  41. package/dist/server/index.js +566 -284
  42. package/dist/server/migrate-command.d.ts +1 -1
  43. package/dist/server/migrate-command.d.ts.map +1 -1
  44. package/dist/server/openapi.d.ts +21 -0
  45. package/dist/server/openapi.d.ts.map +1 -1
  46. package/dist/server/repo.d.ts.map +1 -1
  47. package/dist/server/serve.d.ts.map +1 -1
  48. package/dist/storage.d.ts +2 -2
  49. package/dist/storage.d.ts.map +1 -1
  50. package/dist/storage.js +1334 -871
  51. package/dist/store.d.ts +12 -3
  52. package/dist/store.d.ts.map +1 -1
  53. package/dist/types/index.d.ts +15 -0
  54. package/dist/types/index.d.ts.map +1 -1
  55. package/package.json +9 -8
  56. package/packaging/macos/build_release_pkg.sh +3 -3
  57. package/packaging/macos/managed_bootstrap.sh +4 -4
  58. package/packaging/macos/scripts/postinstall +2 -2
  59. package/packaging/macos/scripts/preinstall +2 -2
  60. package/scripts/build_companion_cli.sh +7 -1
  61. package/scripts/install_macos_app.sh +27 -24
  62. package/scripts/macos_artifact.ts +20 -16
  63. package/scripts/migrate.ts +4 -20
  64. package/scripts/smoke_macos_app.sh +21 -21
  65. package/src/native/Recordings/App/ContentView.swift +53 -68
  66. package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
  67. package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
  68. package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
  69. package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
  70. package/src/native/Recordings/App/RecordingsApp.swift +185 -9
  71. package/src/native/Recordings/App/RecordingsListView.swift +60 -112
  72. package/src/native/Recordings/App/RecordingsStore.swift +124 -109
  73. package/src/native/Recordings/App/Theme.swift +73 -61
  74. package/src/native/Recordings/Package.swift +1 -0
  75. package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
  76. package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
  77. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
  78. package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
  79. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +316 -34
  80. package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
  81. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +102 -0
  82. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
  83. package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
  84. package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
  85. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
  86. package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
  87. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +13 -4
  88. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
  89. package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
  90. package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
  91. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +195 -0
  92. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
  93. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +30 -0
  94. package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
  95. package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
  96. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
  97. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
  98. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
  99. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
  100. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
  101. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
  102. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
  103. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
  104. package/src/native/Recordings/build.sh +6 -6
  105. 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.4.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: {
@@ -56,10 +55,10 @@ var init_package = __esm(() => {
56
55
  scripts: {
57
56
  clean: "rm -rf dist",
58
57
  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",
58
+ "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",
59
+ "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",
60
+ "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",
61
+ "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",
63
62
  "build:native-fs-guard": "/bin/bash scripts/build_native_fs_guard.sh",
64
63
  "generate:sdk": "bun run scripts/generate-sdk.ts",
65
64
  migrate: "bun run scripts/migrate.ts",
@@ -69,7 +68,7 @@ var init_package = __esm(() => {
69
68
  "verify:ci-suite": "bun scripts/ci-linux-suite.ts --check",
70
69
  "verify:ci-suite:run": "bun scripts/ci-linux-suite.ts --verify-run",
71
70
  "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)',
71
+ "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:-120000}" $(bun scripts/ci-linux-suite.ts --gated)',
73
72
  "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
73
  "test:vacuity-battery:source": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/source-side.tsv",
75
74
  "test:vacuity-battery:chain": "bun scripts/vacuity-mutation-battery.ts scripts/vacuity-manifests/install-chain.tsv",
@@ -106,7 +105,8 @@ var init_package = __esm(() => {
106
105
  "LICENSE"
107
106
  ],
108
107
  dependencies: {
109
- "@hasna/contracts": "0.14.1",
108
+ "@aws-sdk/client-s3": "^3.1007.0",
109
+ "@hasna/contracts": "1.0.2",
110
110
  "@hasna/events": "0.1.11",
111
111
  "@modelcontextprotocol/sdk": "^1.12.1",
112
112
  chalk: "^5.4.1",
@@ -116,7 +116,7 @@ var init_package = __esm(() => {
116
116
  zod: "^3.24.2"
117
117
  },
118
118
  devDependencies: {
119
- "@types/bun": "^1.2.5",
119
+ "@types/bun": "1.3.14",
120
120
  "@types/pg": "^8.11.11",
121
121
  "node-api-headers": "1.9.0",
122
122
  typescript: "^5.8.2"
@@ -203,6 +203,9 @@ var init_remote_storage = () => {};
203
203
  function resolveDatabaseUrl(env = process.env) {
204
204
  return env.HASNA_RECORDINGS_DATABASE_URL || env.RECORDINGS_DATABASE_URL || env.DATABASE_URL || undefined;
205
205
  }
206
+ function resolveMigrationDatabaseUrl(env = process.env) {
207
+ return env.HASNA_RECORDINGS_MIGRATE_DATABASE_URL || env.RECORDINGS_MIGRATE_DATABASE_URL || resolveDatabaseUrl(env);
208
+ }
206
209
  function resolveDataBackend(env = process.env) {
207
210
  return resolveDatabaseUrl(env) ? "postgresql" : "sqlite";
208
211
  }
@@ -210,7 +213,7 @@ function isPostgresBackendEnabled(env = process.env) {
210
213
  return resolveDataBackend(env) === "postgresql";
211
214
  }
212
215
  function resolveSigningSecret(env = process.env) {
213
- return env.HASNA_RECORDINGS_API_SIGNING_KEY || env.HASNA_API_SIGNING_KEY || env.API_KEY_SIGNING_SECRET || undefined;
216
+ return env.HASNA_RECORDINGS_API_SIGNING_KEY?.trim() || env.HASNA_API_SIGNING_KEY?.trim() || env.API_KEY_SIGNING_SECRET?.trim() || undefined;
214
217
  }
215
218
  function requireSigningSecret(env = process.env) {
216
219
  const signingSecret = resolveSigningSecret(env);
@@ -224,13 +227,6 @@ function requireSigningSecret(env = process.env) {
224
227
  }
225
228
 
226
229
  // 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
230
  function sameColumns(actual, expected) {
235
231
  return actual.length === expected.length && actual.every((column, index) => column === expected[index]);
236
232
  }
@@ -365,6 +361,16 @@ async function assertCloudSchemaReady(pg2) {
365
361
  AND has_schema_privilege(current_user, schema_row.oid, 'CREATE')
366
362
  ) AS can_create_in_user_schema,
367
363
  has_schema_privilege(current_user, 'public', 'USAGE') AS can_use_public,
364
+ COALESCE((
365
+ SELECT has_column_privilege(table_row.relowner, table_row.oid, 'recording_id', 'SELECT') AND
366
+ has_column_privilege(table_row.relowner, table_row.oid, 'recording_id', 'UPDATE')
367
+ FROM pg_class table_row WHERE table_row.oid = 'public.recording_idempotency'::regclass
368
+ ), false) AS owner_can_tombstone_recording_idempotency,
369
+ COALESCE((
370
+ SELECT has_column_privilege(table_row.relowner, table_row.oid, 'recording_id', 'SELECT') AND
371
+ has_table_privilege(table_row.relowner, table_row.oid, 'DELETE')
372
+ FROM pg_class table_row WHERE table_row.oid = 'public.recording_tags'::regclass
373
+ ), false) AS owner_can_delete_recording_tags,
368
374
  EXISTS (
369
375
  SELECT 1
370
376
  FROM pg_class table_row
@@ -451,6 +457,12 @@ async function assertCloudSchemaReady(pg2) {
451
457
  if (excessive.length > 0) {
452
458
  throw new Error(`cloud database role has surplus table privileges beyond runtime use: ${excessive.join(", ")}`);
453
459
  }
460
+ if (!row?.owner_can_tombstone_recording_idempotency) {
461
+ throw new Error("cloud database recording_idempotency owner lacks SELECT/UPDATE privileges required for recording deletion");
462
+ }
463
+ if (!row?.owner_can_delete_recording_tags) {
464
+ throw new Error("cloud database recording_tags owner lacks SELECT/DELETE privileges required for recording deletion");
465
+ }
454
466
  }
455
467
  async function pingCloudConnectivity(pg2) {
456
468
  const res = await pg2.get("SELECT 1 as ok");
@@ -488,6 +500,9 @@ var init_cloud_readiness = __esm(() => {
488
500
  id: ["text", false],
489
501
  audio_path: ["text", true],
490
502
  raw_text: ["text", false],
503
+ audio_object_key: ["text", true],
504
+ audio_sha256: ["text", true],
505
+ audio_bytes: ["bigint", true],
491
506
  processed_text: ["text", true],
492
507
  processing_mode: ["text", false],
493
508
  model_used: ["text", false],
@@ -694,16 +709,14 @@ var init_pg_migrations = __esm(() => {
694
709
  ALTER COLUMN recording_id DROP NOT NULL;
695
710
  ALTER TABLE recording_idempotency
696
711
  ADD CONSTRAINT recording_idempotency_recording_id_fkey
697
- FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE SET NULL`
712
+ FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE SET NULL`,
713
+ `ALTER TABLE recordings ADD COLUMN IF NOT EXISTS audio_object_key TEXT`,
714
+ `ALTER TABLE recordings ADD COLUMN IF NOT EXISTS audio_sha256 TEXT`,
715
+ `ALTER TABLE recordings ADD COLUMN IF NOT EXISTS audio_bytes BIGINT`
698
716
  ];
699
717
  });
700
718
 
701
719
  // src/server/migrate-command.ts
702
- var exports_migrate_command = {};
703
- __export(exports_migrate_command, {
704
- runCloudMigration: () => runCloudMigration,
705
- applyRecordedCloudMigrations: () => applyRecordedCloudMigrations
706
- });
707
720
  async function applyRecordedCloudMigrations(pg2, ensureApiKeySchema) {
708
721
  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
722
  const applied = await pg2.all("SELECT version FROM _pg_migrations ORDER BY version");
@@ -720,10 +733,12 @@ async function applyRecordedCloudMigrations(pg2, ensureApiKeySchema) {
720
733
  }
721
734
  await ensureApiKeySchema();
722
735
  }
723
- async function runCloudMigration(steps) {
736
+ async function runCloudMigration(steps, runtimePostureValidate) {
724
737
  await steps.pingConnectivity();
725
738
  await steps.applyMigrations();
726
739
  await steps.validateContract();
740
+ if (runtimePostureValidate)
741
+ await runtimePostureValidate();
727
742
  }
728
743
  var init_migrate_command = __esm(() => {
729
744
  init_pg_migrations();
@@ -732,7 +747,9 @@ var init_migrate_command = __esm(() => {
732
747
  // src/server/cloud.ts
733
748
  var exports_cloud = {};
734
749
  __export(exports_cloud, {
750
+ runRecordedMigration: () => runRecordedMigration,
735
751
  resolveSigningSecret: () => resolveSigningSecret,
752
+ resolveMigrationDatabaseUrl: () => resolveMigrationDatabaseUrl,
736
753
  resolveDatabaseUrl: () => resolveDatabaseUrl,
737
754
  resolveDataBackend: () => resolveDataBackend,
738
755
  requireSigningSecret: () => requireSigningSecret,
@@ -740,6 +757,7 @@ __export(exports_cloud, {
740
757
  pingCloud: () => pingCloud,
741
758
  migrateCloudSchema: () => migrateCloudSchema,
742
759
  isPostgresBackendEnabled: () => isPostgresBackendEnabled,
760
+ getMigrationPg: () => getMigrationPg,
743
761
  getCloudVerifier: () => getCloudVerifier,
744
762
  getCloudPg: () => getCloudPg,
745
763
  getApiKeyStore: () => getApiKeyStore,
@@ -759,8 +777,7 @@ function getCloudPg() {
759
777
  cachedPg = new PgAdapterAsync(url);
760
778
  return cachedPg;
761
779
  }
762
- function authClient() {
763
- const pg2 = getCloudPg();
780
+ function authClient(pg2 = getCloudPg()) {
764
781
  return {
765
782
  async many(sql, params = []) {
766
783
  return await pg2.all(sql, ...params);
@@ -796,8 +813,30 @@ function getCloudVerifier() {
796
813
  });
797
814
  return cachedVerifier;
798
815
  }
799
- async function migrateCloudSchema() {
800
- await applyRecordedCloudMigrations(getCloudPg(), () => getApiKeyStore().ensureSchema());
816
+ function getMigrationPg() {
817
+ const url = resolveMigrationDatabaseUrl();
818
+ if (!url) {
819
+ 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).");
820
+ }
821
+ return new PgAdapterAsync(url);
822
+ }
823
+ async function migrateCloudSchema(pg2 = getCloudPg()) {
824
+ await applyRecordedCloudMigrations(pg2, () => new ApiKeyStore(authClient(pg2)).ensureSchema());
825
+ }
826
+ async function runRecordedMigration() {
827
+ const migrationPg = getMigrationPg();
828
+ const runtimeUrl = resolveDatabaseUrl();
829
+ const migrationUrl = resolveMigrationDatabaseUrl();
830
+ const separateRoles = runtimeUrl !== undefined && migrationUrl !== undefined && runtimeUrl !== migrationUrl;
831
+ await runCloudMigration({
832
+ pingConnectivity: () => pingCloudConnectivity2(migrationPg),
833
+ applyMigrations: async () => {
834
+ await migrateCloudSchema(migrationPg);
835
+ },
836
+ validateContract: () => assertCloudSchemaContract(migrationPg)
837
+ }, separateRoles ? async () => {
838
+ await assertCloudSchemaReady(getCloudPg());
839
+ } : undefined);
801
840
  }
802
841
  async function ensureCloudSchema() {
803
842
  if (schemaEnsured)
@@ -942,6 +981,9 @@ function parseRecording(row) {
942
981
  return {
943
982
  id: row["id"],
944
983
  audio_path: row["audio_path"] || null,
984
+ audio_object_key: row["audio_object_key"] || null,
985
+ audio_sha256: row["audio_sha256"] || null,
986
+ audio_bytes: Number(row["audio_bytes"] ?? 0) || null,
945
987
  raw_text: row["raw_text"],
946
988
  processed_text: row["processed_text"] || null,
947
989
  processing_mode: row["processing_mode"] || "raw",
@@ -1031,9 +1073,9 @@ async function createRecording(pg2, input, idempotencyKey, idempotencyContext) {
1031
1073
  throw new ProjectNotFoundError(input.project_id);
1032
1074
  resolvedProjectId = project.id;
1033
1075
  }
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 || {}));
1076
+ 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)
1077
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1078
+ 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
1079
  if (insertResult.changes === 0) {
1038
1080
  throw new IdempotencyConflictError;
1039
1081
  }
@@ -1454,6 +1496,9 @@ function buildV1OpenApiDocument(version = VERSION) {
1454
1496
  id: { type: "string" },
1455
1497
  raw_text: { type: "string" },
1456
1498
  audio_path: { type: "string" },
1499
+ audio_object_key: { type: "string" },
1500
+ audio_sha256: { type: "string" },
1501
+ audio_bytes: { type: "number" },
1457
1502
  processed_text: { type: "string" },
1458
1503
  processing_mode: { type: "string", enum: ["raw", "enhanced"] },
1459
1504
  model_used: { type: "string" },
@@ -1742,6 +1787,9 @@ var init_openapi = __esm(() => {
1742
1787
  properties: {
1743
1788
  id: { type: "string" },
1744
1789
  audio_path: { type: "string", nullable: true },
1790
+ audio_object_key: { type: "string", nullable: true },
1791
+ audio_sha256: { type: "string", nullable: true },
1792
+ audio_bytes: { type: "number", nullable: true },
1745
1793
  raw_text: { type: "string" },
1746
1794
  processed_text: { type: "string", nullable: true },
1747
1795
  processing_mode: { type: "string", enum: ["raw", "enhanced"] },
@@ -1786,6 +1834,172 @@ var init_openapi = __esm(() => {
1786
1834
  };
1787
1835
  });
1788
1836
 
1837
+ // src/server/client-ip.ts
1838
+ function normalizeIpLiteral(raw) {
1839
+ let value = (raw ?? "").trim();
1840
+ if (!value)
1841
+ return null;
1842
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(value);
1843
+ if (bracketed)
1844
+ value = bracketed[1];
1845
+ else if (/^\d{1,3}(?:\.\d{1,3}){3}:\d+$/.test(value))
1846
+ value = value.slice(0, value.lastIndexOf(":"));
1847
+ value = value.trim().toLowerCase();
1848
+ if (!value || value.length > 45)
1849
+ return null;
1850
+ const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(value);
1851
+ if (mapped)
1852
+ value = mapped[1];
1853
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(value);
1854
+ if (ipv4) {
1855
+ return ipv4.slice(1).every((octet) => Number(octet) <= 255 && !/^0\d/.test(octet)) ? value : null;
1856
+ }
1857
+ if (/^[0-9a-f:]+(?:\.\d{1,3}){0,3}$/.test(value) && value.includes(":") && !/:::/.test(value)) {
1858
+ return value;
1859
+ }
1860
+ return null;
1861
+ }
1862
+ function parseForwardedFor(header) {
1863
+ if (!header)
1864
+ return [];
1865
+ return header.split(",").map((entry) => normalizeIpLiteral(entry)).filter((entry) => entry !== null);
1866
+ }
1867
+ function parseCidr(entry) {
1868
+ const slash = entry.indexOf("/");
1869
+ const rawAddress = slash >= 0 ? entry.slice(0, slash) : entry;
1870
+ const rawPrefix = slash >= 0 ? entry.slice(slash + 1) : null;
1871
+ const address = normalizeIpLiteral(rawAddress);
1872
+ if (!address)
1873
+ return null;
1874
+ if (address.includes(":")) {
1875
+ if (rawPrefix !== null && !/^\d{1,3}$/.test(rawPrefix))
1876
+ return null;
1877
+ const prefix2 = rawPrefix === null ? 128 : Number(rawPrefix);
1878
+ if (prefix2 < 0 || prefix2 > 128)
1879
+ return null;
1880
+ const bytes2 = expandV6(address);
1881
+ if (!bytes2)
1882
+ return null;
1883
+ const masked2 = maskNetwork(bytes2, prefix2);
1884
+ return { address, prefix: prefix2, bytes: masked2, family: "ipv6" };
1885
+ }
1886
+ if (rawPrefix !== null && !/^\d{1,3}$/.test(rawPrefix))
1887
+ return null;
1888
+ const prefix = rawPrefix === null ? 32 : Number(rawPrefix);
1889
+ if (prefix < 0 || prefix > 32)
1890
+ return null;
1891
+ const bytes = address.split(".").map((octet) => Number(octet));
1892
+ const masked = maskNetwork(bytes, prefix);
1893
+ return { address, prefix, bytes: masked, family: "ipv4" };
1894
+ }
1895
+ function expandV6(address) {
1896
+ const compressed = address.split("::");
1897
+ if (compressed.length > 2)
1898
+ return null;
1899
+ let groups = [];
1900
+ if (compressed.length === 2) {
1901
+ const left = compressed[0] ? compressed[0].split(":") : [];
1902
+ const right = compressed[1] ? compressed[1].split(":") : [];
1903
+ const missing = 8 - left.length - right.length;
1904
+ if (missing < 1)
1905
+ return null;
1906
+ groups = [...left, ...Array(missing).fill("0"), ...right];
1907
+ } else {
1908
+ groups = address.split(":");
1909
+ }
1910
+ if (groups.length !== 8)
1911
+ return null;
1912
+ const bytes = [];
1913
+ for (const group of groups) {
1914
+ if (!/^[0-9a-f]{1,4}$/.test(group))
1915
+ return null;
1916
+ const value = Number.parseInt(group, 16);
1917
+ bytes.push(value >> 8 & 255, value & 255);
1918
+ }
1919
+ return bytes;
1920
+ }
1921
+ function maskNetwork(bytes, prefix) {
1922
+ const masked = bytes.slice();
1923
+ const fullBytes = Math.floor(prefix / 8);
1924
+ const remainder = prefix % 8;
1925
+ for (let i = 0;i < masked.length; i++) {
1926
+ if (i >= fullBytes) {
1927
+ if (i === fullBytes && remainder > 0) {
1928
+ masked[i] = masked[i] & (255 << 8 - remainder & 255);
1929
+ } else {
1930
+ masked[i] = 0;
1931
+ }
1932
+ }
1933
+ }
1934
+ return masked;
1935
+ }
1936
+ function parseTrustedProxies(raw) {
1937
+ if (!raw)
1938
+ return [];
1939
+ const seen = new Set;
1940
+ const trusted = [];
1941
+ for (const entry of raw.split(",").map((part) => part.trim()).filter(Boolean)) {
1942
+ const cidr = parseCidr(entry);
1943
+ if (cidr && !seen.has(entry)) {
1944
+ seen.add(entry);
1945
+ trusted.push(entry);
1946
+ }
1947
+ }
1948
+ return trusted;
1949
+ }
1950
+ function isMatch(candidateBytes, cidr) {
1951
+ if (cidr.bytes.length !== candidateBytes.length)
1952
+ return false;
1953
+ const masked = maskNetwork(candidateBytes, cidr.prefix);
1954
+ for (let i = 0;i < cidr.bytes.length; i++) {
1955
+ if (cidr.bytes[i] !== masked[i])
1956
+ return false;
1957
+ }
1958
+ return true;
1959
+ }
1960
+ function isTrustedAddress(ip, trusted) {
1961
+ const address = normalizeIpLiteral(ip);
1962
+ if (!address)
1963
+ return false;
1964
+ const cidrs = trusted.map(parseCidr).filter((cidr) => cidr !== null);
1965
+ for (const cidr of cidrs) {
1966
+ if (cidr.address === address)
1967
+ return true;
1968
+ const bytes = cidr.family === "ipv4" ? address.split(".").map((octet) => Number(octet)) : expandV6(address);
1969
+ if (bytes && isMatch(bytes, cidr))
1970
+ return true;
1971
+ }
1972
+ return false;
1973
+ }
1974
+ function resolveTrustProxy(env = process.env) {
1975
+ return TRUST_TRUE_VALUES.has((env[TRUST_PROXY_ENV] ?? "").trim().toLowerCase());
1976
+ }
1977
+ function trustedProxiesFromEnv(env = process.env) {
1978
+ return parseTrustedProxies(env[TRUSTED_PROXIES_ENV]);
1979
+ }
1980
+ function resolveRequestClientIp(input) {
1981
+ const socket = normalizeIpLiteral(input.socketAddress);
1982
+ if (input.trustProxy !== true)
1983
+ return socket;
1984
+ const trusted = input.trustedProxies ?? [];
1985
+ const real = normalizeIpLiteral(input.headers.get("x-real-ip"));
1986
+ if (real)
1987
+ return real;
1988
+ const chain = parseForwardedFor(input.headers.get("x-forwarded-for"));
1989
+ for (let i = chain.length - 1;i >= 0; i--) {
1990
+ const hop = chain[i];
1991
+ if (!isTrustedAddress(hop, trusted))
1992
+ return hop;
1993
+ }
1994
+ if (chain.length > 0)
1995
+ return chain[0];
1996
+ return socket;
1997
+ }
1998
+ var TRUST_PROXY_ENV = "RECORDINGS_TRUST_PROXY", TRUSTED_PROXIES_ENV = "RECORDINGS_TRUSTED_PROXIES", TRUST_TRUE_VALUES;
1999
+ var init_client_ip = __esm(() => {
2000
+ TRUST_TRUE_VALUES = new Set(["1", "true", "yes", "on"]);
2001
+ });
2002
+
1789
2003
  // src/mcp/http.ts
1790
2004
  var exports_http = {};
1791
2005
  __export(exports_http, {
@@ -5888,32 +6102,6 @@ function assertMaintenanceAbsent(env) {
5888
6102
  if (pathExists(installMaintenanceMarkerPath(env)))
5889
6103
  throw maintenanceUnavailable();
5890
6104
  }
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
6105
  function removeActiveLease(activeLease, releasedLease) {
5918
6106
  try {
5919
6107
  renameSync(activeLease, releasedLease);
@@ -5999,6 +6187,9 @@ function loadConfig(configPath) {
5999
6187
  if (process.env.RECORDINGS_API_KEY) {
6000
6188
  config.openai_api_key = process.env.RECORDINGS_API_KEY;
6001
6189
  }
6190
+ if (process.env.RECORDINGS_OPENAI_API_KEY) {
6191
+ config.openai_api_key = process.env.RECORDINGS_OPENAI_API_KEY;
6192
+ }
6002
6193
  if (process.env.RECORDINGS_ENHANCEMENT_KEY) {
6003
6194
  config.enhancement_api_key = process.env.RECORDINGS_ENHANCEMENT_KEY;
6004
6195
  }
@@ -6042,14 +6233,26 @@ function loadConfig(configPath) {
6042
6233
  if (process.env.RECORDINGS_AUDIO_DIR) {
6043
6234
  config.audio_dir = process.env.RECORDINGS_AUDIO_DIR;
6044
6235
  }
6236
+ if (process.env.HASNA_RECORDINGS_S3_BUCKET) {
6237
+ config.s3_bucket = process.env.HASNA_RECORDINGS_S3_BUCKET;
6238
+ } else if (process.env.RECORDINGS_S3_BUCKET) {
6239
+ config.s3_bucket = process.env.RECORDINGS_S3_BUCKET;
6240
+ }
6241
+ if (process.env.HASNA_RECORDINGS_S3_PREFIX) {
6242
+ config.s3_prefix = process.env.HASNA_RECORDINGS_S3_PREFIX;
6243
+ } else if (process.env.RECORDINGS_S3_PREFIX) {
6244
+ config.s3_prefix = process.env.RECORDINGS_S3_PREFIX;
6245
+ }
6246
+ if (process.env.RECORDINGS_S3_REGION) {
6247
+ config.s3_region = process.env.RECORDINGS_S3_REGION;
6248
+ } else if (process.env.AWS_REGION) {
6249
+ config.s3_region = process.env.AWS_REGION;
6250
+ }
6045
6251
  if (process.env.RECORDINGS_MAX_SECONDS) {
6046
6252
  config.max_recording_seconds = parseInt(process.env.RECORDINGS_MAX_SECONDS, 10);
6047
6253
  }
6048
- if (!config.openai_api_key) {
6049
- config.openai_api_key = loadSecretKey("OPENAI_API_KEY");
6050
- }
6051
6254
  if (!config.enhancement_api_key) {
6052
- config.enhancement_api_key = config.openai_api_key || loadSecretKey("OPENAI_API_KEY");
6255
+ config.enhancement_api_key = config.openai_api_key;
6053
6256
  }
6054
6257
  if (!explicitTranscriberModel) {
6055
6258
  config.transcriber_model = config.enhancement_model;
@@ -6229,66 +6432,9 @@ function mergeDirectoryContents(sourceDir, targetDir) {
6229
6432
  }
6230
6433
  }
6231
6434
  }
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
6435
  function getHomeDir() {
6278
6436
  return process.env["HOME"] || process.env["USERPROFILE"] || homedir2();
6279
6437
  }
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
6438
  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
6439
  var init_config = __esm(() => {
6294
6440
  init_install_maintenance();
@@ -6330,6 +6476,9 @@ var init_config = __esm(() => {
6330
6476
  db_path: "",
6331
6477
  audio_dir: "",
6332
6478
  max_recording_seconds: 1800,
6479
+ s3_bucket: "",
6480
+ s3_prefix: "",
6481
+ s3_region: "",
6333
6482
  config_warnings: []
6334
6483
  };
6335
6484
  });
@@ -6421,6 +6570,9 @@ function repairSchemaDrift(db) {
6421
6570
  ensureColumn(db, "recordings", "task_list_id", "TEXT");
6422
6571
  ensureColumn(db, "recordings", "machine_id", "TEXT");
6423
6572
  ensureColumn(db, "recordings", "metadata", "TEXT DEFAULT '{}'");
6573
+ ensureColumn(db, "recordings", "audio_object_key", "TEXT");
6574
+ ensureColumn(db, "recordings", "audio_sha256", "TEXT");
6575
+ ensureColumn(db, "recordings", "audio_bytes", "INTEGER");
6424
6576
  ensureColumn(db, "agents", "active_project_id", "TEXT REFERENCES projects(id) ON DELETE SET NULL");
6425
6577
  }
6426
6578
  function ensureColumn(db, table, column, definition) {
@@ -6514,6 +6666,11 @@ var init_database = __esm(() => {
6514
6666
  `,
6515
6667
  `
6516
6668
  ALTER TABLE agents ADD COLUMN active_project_id TEXT REFERENCES projects(id) ON DELETE SET NULL;
6669
+ `,
6670
+ `
6671
+ ALTER TABLE recordings ADD COLUMN audio_object_key TEXT;
6672
+ ALTER TABLE recordings ADD COLUMN audio_sha256 TEXT;
6673
+ ALTER TABLE recordings ADD COLUMN audio_bytes INTEGER;
6517
6674
  `
6518
6675
  ];
6519
6676
  CURRENT_MIGRATION_LEVEL = MIGRATIONS.length - 1;
@@ -6524,6 +6681,9 @@ function parseRow(row) {
6524
6681
  return {
6525
6682
  id: row["id"],
6526
6683
  audio_path: row["audio_path"] || null,
6684
+ audio_object_key: row["audio_object_key"] || null,
6685
+ audio_sha256: row["audio_sha256"] || null,
6686
+ audio_bytes: Number(row["audio_bytes"] ?? 0) || null,
6527
6687
  raw_text: row["raw_text"],
6528
6688
  processed_text: row["processed_text"] || null,
6529
6689
  processing_mode: row["processing_mode"] || "raw",
@@ -6555,9 +6715,9 @@ function createRecording2(input, db, idempotencyKey) {
6555
6715
  }
6556
6716
  const tagsJson = JSON.stringify(input.tags || []);
6557
6717
  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);
6718
+ 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)
6719
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
6720
+ 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
6721
  if (insertResult.changes === 0) {
6562
6722
  const existing = getRecording2(id, d);
6563
6723
  if (existing?.id === id)
@@ -6574,6 +6734,13 @@ function createRecording2(input, db, idempotencyKey) {
6574
6734
  });
6575
6735
  return create();
6576
6736
  }
6737
+ function updateRecordingAudio(id, audioObjectKey, audioSha256, audioBytes, db) {
6738
+ const d = db || getDatabase();
6739
+ const result = d.query(`UPDATE recordings SET audio_object_key = ?, audio_sha256 = ?, audio_bytes = ? WHERE id = ?`).run(audioObjectKey, audioSha256, audioBytes, id);
6740
+ if (result.changes === 0)
6741
+ return null;
6742
+ return getRecording2(id, d);
6743
+ }
6577
6744
  function getRecording2(id, db) {
6578
6745
  const d = db || getDatabase();
6579
6746
  let row = d.query("SELECT * FROM recordings WHERE id = ?").get(id);
@@ -6795,151 +6962,266 @@ var init_feedback = __esm(() => {
6795
6962
  init_version();
6796
6963
  });
6797
6964
 
6965
+ // src/lib/local-opt-in.ts
6966
+ import {
6967
+ clientTransportEnvKeys,
6968
+ credentialOverrideEnvKey,
6969
+ credentialPointerEnvKey,
6970
+ CREDENTIAL_PROFILE_ENV_KEY
6971
+ } from "@hasna/contracts/client";
6972
+ function isRecordingsLocalOptIn(env = process.env) {
6973
+ return RECORDINGS_LOCAL_OPT_IN_ENV_KEYS.some((key) => (env[key] ?? "").trim() !== "");
6974
+ }
6975
+ function recordingsAuthorityEnvKeys() {
6976
+ const keys = clientTransportEnvKeys("recordings");
6977
+ return [
6978
+ ...keys.apiUrlKeys,
6979
+ ...keys.apiKeyKeys,
6980
+ credentialOverrideEnvKey("recordings"),
6981
+ credentialPointerEnvKey("recordings"),
6982
+ CREDENTIAL_PROFILE_ENV_KEY
6983
+ ].filter((key) => !RECORDINGS_CARVED_LEGACY_ENV_KEYS.includes(key));
6984
+ }
6985
+ function hasRecordingsEnvAuthorityIntent(env = process.env) {
6986
+ return recordingsAuthorityEnvKeys().some((key) => (env[key] ?? "").trim() !== "");
6987
+ }
6988
+ function selectsRecordingsLocalStore(env = process.env) {
6989
+ return !hasRecordingsEnvAuthorityIntent(env) && isRecordingsLocalOptIn(env);
6990
+ }
6991
+ function recordingsResolverEnv(env) {
6992
+ const carved = RECORDINGS_CARVED_LEGACY_ENV_KEYS.filter((key) => (key in env));
6993
+ const blanks = recordingsAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
6994
+ const removed = new Set([...carved, ...blanks]);
6995
+ if (removed.size === 0)
6996
+ return env;
6997
+ const next = { ...env };
6998
+ for (const key of removed)
6999
+ delete next[key];
7000
+ return next;
7001
+ }
7002
+ function isAmbientRecordingsEnv(env) {
7003
+ if (typeof process !== "undefined" && env === process.env) {
7004
+ return true;
7005
+ }
7006
+ return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
7007
+ }
7008
+ function recordingsResolverInputs(env, credentials = {}) {
7009
+ const normalised = recordingsResolverEnv(env);
7010
+ if (normalised === env)
7011
+ return { env: normalised, credentials };
7012
+ const keychain = { ...credentials.keychain };
7013
+ if (keychain.enabled === undefined && keychain.run === undefined) {
7014
+ keychain.enabled = isAmbientRecordingsEnv(env);
7015
+ }
7016
+ return { env: normalised, credentials: { ...credentials, keychain } };
7017
+ }
7018
+ var RECORDINGS_LOCAL_OPT_IN_ENV_KEYS, RECORDINGS_CARVED_LEGACY_ENV_KEYS, CONTRACTS_AMBIENT_ENVIRONMENT;
7019
+ var init_local_opt_in = __esm(() => {
7020
+ RECORDINGS_LOCAL_OPT_IN_ENV_KEYS = [
7021
+ "HASNA_RECORDINGS_LOCAL",
7022
+ "RECORDINGS_LOCAL"
7023
+ ];
7024
+ RECORDINGS_CARVED_LEGACY_ENV_KEYS = [
7025
+ "RECORDINGS_API_URL",
7026
+ "RECORDINGS_API_KEY"
7027
+ ];
7028
+ CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
7029
+ });
7030
+
6798
7031
  // src/http/client.ts
6799
- import { createClientTransport } from "@hasna/contracts/client";
6800
7032
  import {
6801
- createHasnaStorageClient,
7033
+ clientTransportEnvKeys as clientTransportEnvKeys2,
7034
+ resolveClientTransport
7035
+ } from "@hasna/contracts/client";
7036
+ import {
6802
7037
  resolveStorageClient
6803
7038
  } 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 };
7039
+ function throwRecordingsAuthorityFailure(error2) {
7040
+ const message = error2 instanceof Error ? error2.message : String(error2);
7041
+ const name = error2 instanceof Error ? error2.name : "";
7042
+ if (name === "CredentialResolutionError" || name === "CredentialFileUnsafeError") {
7043
+ 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
7044
  }
6829
- return null;
7045
+ if (/no API key could be resolved/.test(message)) {
7046
+ if (/is not set and no API key could be resolved/.test(message)) {
7047
+ 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 });
7048
+ }
7049
+ 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 });
7050
+ }
7051
+ throw new Error(`REMOTE_API_URL_INVALID: ${message} local SQLite fallback is disabled`, { cause: error2 });
6830
7052
  }
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
- };
7053
+ function resolveRecordingsCloudClient(env = process.env, options = {}) {
7054
+ if (selectsRecordingsLocalStore(env))
7055
+ return null;
7056
+ const resolverInputs = recordingsResolverInputs(env, options.credentials);
7057
+ let resolved;
7058
+ try {
7059
+ resolved = resolveStorageClient("recordings", resolverInputs.env, {
7060
+ fetchImpl: (input, init) => globalThis.fetch(input, { ...init, redirect: "manual" }),
7061
+ credentials: resolverInputs.credentials
7062
+ });
7063
+ } catch (error2) {
7064
+ throwRecordingsAuthorityFailure(error2);
6869
7065
  }
6870
- if (requested === "sqlite") {
6871
- return { transport: "sqlite", requested, modeSource, baseUrl: null, apiKeyPresent: Boolean(keyHit), misconfigured: false, warning: null };
7066
+ const client = resolved.client;
7067
+ return client;
7068
+ }
7069
+ var RETRY_STATUSES, IDEMPOTENT;
7070
+ var init_client = __esm(() => {
7071
+ init_local_opt_in();
7072
+ RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
7073
+ IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
7074
+ });
7075
+
7076
+ // src/lib/audio-artifact-storage.ts
7077
+ import { GetObjectCommand, PutObjectCommand, S3Client as AwsS3Client } from "@aws-sdk/client-s3";
7078
+ import { createHash as createHash2 } from "crypto";
7079
+ import { readFile } from "fs/promises";
7080
+ import { extname } from "path";
7081
+ function contentTypeForExtension(extension) {
7082
+ return AUDIO_CONTENT_TYPES[extension.toLowerCase()] ?? "application/octet-stream";
7083
+ }
7084
+
7085
+ class AudioArtifactStorage {
7086
+ bucket;
7087
+ prefix;
7088
+ s3;
7089
+ constructor(options = {}) {
7090
+ this.bucket = options.bucket;
7091
+ this.prefix = (options.prefix || DEFAULT_S3_PREFIX).replace(/^\/+|\/+$/g, "");
7092
+ this.s3 = this.bucket ? options.client ?? new AwsS3Client({ region: options.region || process.env.AWS_REGION || "us-east-1" }) : undefined;
7093
+ }
7094
+ get usesS3() {
7095
+ return Boolean(this.bucket);
7096
+ }
7097
+ objectKeyFor(recordingId, sha256, extension) {
7098
+ assertSha256(sha256);
7099
+ const safeExtension = extension.toLowerCase().match(/^[a-z0-9]+/)?.[0] || "bin";
7100
+ return `${this.prefix}/${recordingId}/${sha256}.${safeExtension}`;
7101
+ }
7102
+ async uploadAudio(recordingId, audioPath) {
7103
+ if (!this.bucket || !this.s3)
7104
+ return null;
7105
+ const bytes = await readFileBytes(audioPath);
7106
+ if (!bytes)
7107
+ return null;
7108
+ const sha256 = sha256Hex(bytes);
7109
+ const extension = extensionOf(audioPath);
7110
+ const key = this.objectKeyFor(recordingId, sha256, extension);
7111
+ await this.s3.send(new PutObjectCommand({
7112
+ Bucket: this.bucket,
7113
+ Key: key,
7114
+ Body: bytes,
7115
+ ContentType: contentTypeForExtension(extension)
7116
+ }));
7117
+ return { objectKey: key, sha256, bytes: bytes.byteLength };
7118
+ }
7119
+ async readAudio(objectKey) {
7120
+ if (!this.bucket || !this.s3)
7121
+ return null;
7122
+ try {
7123
+ const response = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }));
7124
+ return await bodyToBytes(response.Body);
7125
+ } catch {
7126
+ return null;
7127
+ }
6872
7128
  }
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
- };
7129
+ }
7130
+ function resolveAudioArtifactStorage(env = process.env, client) {
7131
+ const bucket = env.HASNA_RECORDINGS_S3_BUCKET || env.RECORDINGS_S3_BUCKET || undefined;
7132
+ const prefix = env.HASNA_RECORDINGS_S3_PREFIX || env.RECORDINGS_S3_PREFIX || undefined;
7133
+ const region = env.RECORDINGS_S3_REGION || env.AWS_REGION || undefined;
7134
+ return new AudioArtifactStorage({ bucket, prefix, region, client });
7135
+ }
7136
+ async function uploadAudioAtCreation(recordingId, audioPath, storage = resolveAudioArtifactStorage()) {
7137
+ if (!audioPath)
7138
+ return null;
7139
+ if (!storage.usesS3)
7140
+ return null;
7141
+ try {
7142
+ const uploaded = await storage.uploadAudio(recordingId, audioPath);
7143
+ if (!uploaded)
7144
+ return null;
7145
+ console.warn(`recordings: uploaded audio for ${recordingId} to ${uploaded.objectKey}`);
7146
+ return uploaded;
7147
+ } catch (error2) {
7148
+ console.warn(`recordings: audio upload failed for ${recordingId} (${String(error2 instanceof Error ? error2.message : error2)}); keeping local audio_path only`);
7149
+ return null;
6883
7150
  }
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
- };
7151
+ }
7152
+ function assertSha256(value) {
7153
+ if (!SHA256_HEX.test(value)) {
7154
+ throw new Error("audio digest must be a lowercase hex sha-256; refusing to build a storage key from it");
6894
7155
  }
6895
- const rawUrl = urlHit.value;
6896
- let baseUrl;
7156
+ }
7157
+ function sha256Hex(bytes) {
7158
+ const hash = createHash2("sha256");
7159
+ hash.update(bytes);
7160
+ return hash.digest("hex");
7161
+ }
7162
+ async function readFileBytes(path) {
6897
7163
  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}.` };
7164
+ return new Uint8Array(await readFile(path));
7165
+ } catch {
7166
+ return null;
6902
7167
  }
6903
- return { transport: "http", requested, modeSource, baseUrl, apiKeyPresent: true, misconfigured: false, warning: null };
6904
7168
  }
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.`);
7169
+ function extensionOf(path) {
7170
+ const extension = extname(path).replace(/^\./, "").toLowerCase();
7171
+ return extension || "bin";
7172
+ }
7173
+ async function bodyToBytes(body) {
7174
+ if (!body)
7175
+ return new Uint8Array(0);
7176
+ if (body instanceof Uint8Array)
7177
+ return body;
7178
+ if (typeof body.transformToByteArray === "function") {
7179
+ return new Uint8Array(await body.transformToByteArray());
6925
7180
  }
6926
- if (resolution.transport === "sqlite" || !resolution.baseUrl) {
6927
- return { transport: "sqlite", client: null, resolution };
7181
+ if (typeof body.getReader === "function") {
7182
+ const reader = body.getReader();
7183
+ const chunks2 = [];
7184
+ for (;; ) {
7185
+ const { value, done } = await reader.read();
7186
+ if (done)
7187
+ break;
7188
+ if (value)
7189
+ chunks2.push(value);
7190
+ }
7191
+ return concat(chunks2);
6928
7192
  }
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.`);
7193
+ const chunks = [];
7194
+ for await (const chunk of body) {
7195
+ chunks.push(typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk);
6932
7196
  }
6933
- return { transport: "http", client: createHasnaStorageClient(name, wired.client), resolution };
7197
+ return concat(chunks);
6934
7198
  }
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"]);
7199
+ function concat(chunks) {
7200
+ const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
7201
+ const merged = new Uint8Array(new ArrayBuffer(total));
7202
+ let offset = 0;
7203
+ for (const chunk of chunks) {
7204
+ merged.set(chunk, offset);
7205
+ offset += chunk.byteLength;
7206
+ }
7207
+ return merged;
7208
+ }
7209
+ var SHA256_HEX, DEFAULT_S3_PREFIX = "recordings", AUDIO_CONTENT_TYPES;
7210
+ var init_audio_artifact_storage = __esm(() => {
7211
+ SHA256_HEX = /^[0-9a-f]{64}$/;
7212
+ AUDIO_CONTENT_TYPES = {
7213
+ wav: "audio/wav",
7214
+ mp3: "audio/mpeg",
7215
+ m4a: "audio/mp4",
7216
+ aac: "audio/aac",
7217
+ webm: "audio/webm",
7218
+ ogg: "audio/ogg",
7219
+ flac: "audio/flac"
7220
+ };
6939
7221
  });
6940
7222
 
6941
7223
  // src/store.ts
6942
- import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
7224
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
6943
7225
  function listQuery(filter) {
6944
7226
  if (!filter)
6945
7227
  return {};
@@ -7111,7 +7393,7 @@ function apiStore(client) {
7111
7393
  };
7112
7394
  }
7113
7395
  function recordingPageFingerprint(items) {
7114
- const hash = createHash2("sha256");
7396
+ const hash = createHash3("sha256");
7115
7397
  const ids = items.map((item) => String(item.id ?? "")).sort();
7116
7398
  for (const id of ids) {
7117
7399
  hash.update(String(id.length));
@@ -7146,16 +7428,19 @@ async function countStoreRecordings(store, filter) {
7146
7428
  }
7147
7429
  throw new Error(`Legacy Store exceeded ${maxPageRequests} pages while counting recordings`);
7148
7430
  }
7149
- function getStore(env = process.env) {
7150
- if (env === process.env && cached)
7431
+ function localArtifactStorageFor() {
7432
+ return localArtifactStorageOverride ?? resolveAudioArtifactStorage();
7433
+ }
7434
+ function getStore(env = process.env, options = {}) {
7435
+ if (env === process.env && cached && options.credentials === undefined)
7151
7436
  return cached;
7152
- const resolved = resolveStoreClient(APP, env);
7153
- const store = resolved.transport === "http" ? apiStore(resolved.client) : localStore;
7154
- if (env === process.env)
7437
+ const client = resolveRecordingsCloudClient(env, options);
7438
+ const store = client ? apiStore(client) : localStore;
7439
+ if (env === process.env && options.credentials === undefined)
7155
7440
  cached = store;
7156
7441
  return store;
7157
7442
  }
7158
- var APP = "recordings", localStore, cached = null;
7443
+ var localStore, cached = null, localArtifactStorageOverride;
7159
7444
  var init_store = __esm(() => {
7160
7445
  init_recordings();
7161
7446
  init_agents();
@@ -7164,11 +7449,20 @@ var init_store = __esm(() => {
7164
7449
  init_client();
7165
7450
  init_recording_create_identity();
7166
7451
  init_install_maintenance();
7452
+ init_audio_artifact_storage();
7167
7453
  localStore = {
7168
7454
  mode: "sqlite",
7169
7455
  baseUrl: null,
7170
7456
  async createRecording(input, idempotencyKey) {
7171
- return withLocalStoreReaderLease(() => createRecording2(input, undefined, idempotencyKey));
7457
+ return withLocalStoreReaderLease(async () => {
7458
+ const recording = createRecording2(input, undefined, idempotencyKey);
7459
+ if (!recording.audio_path)
7460
+ return recording;
7461
+ const uploaded = await uploadAudioAtCreation(recording.id, recording.audio_path, localArtifactStorageFor());
7462
+ if (!uploaded)
7463
+ return recording;
7464
+ return updateRecordingAudio(recording.id, uploaded.objectKey, uploaded.sha256, uploaded.bytes) ?? recording;
7465
+ });
7172
7466
  },
7173
7467
  async getRecording(id) {
7174
7468
  return withLocalStoreReaderLease(() => getRecording2(id));
@@ -7240,7 +7534,7 @@ import OpenAI from "openai";
7240
7534
  import { createReadStream } from "fs";
7241
7535
  function getClient(config) {
7242
7536
  if (!config.openai_api_key) {
7243
- throw new TranscriptionError("OpenAI API key not configured. Set OPENAI_API_KEY env var or add to ~/.secrets");
7537
+ 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
7538
  }
7245
7539
  if (_client && _clientApiKey === config.openai_api_key)
7246
7540
  return _client;
@@ -8153,8 +8447,9 @@ var init_mcp = __esm(async () => {
8153
8447
  init_enhancer();
8154
8448
  init_version();
8155
8449
  init_machine();
8450
+ init_local_opt_in();
8451
+ init_client();
8156
8452
  config = loadConfig();
8157
- ensureDataDir(config);
8158
8453
  if (false) {}
8159
8454
  });
8160
8455
 
@@ -8172,13 +8467,12 @@ function jsonResponse(data, status = 200, headers) {
8172
8467
  });
8173
8468
  }
8174
8469
  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";
8470
+ return resolveRequestClientIp({
8471
+ headers: req.headers,
8472
+ socketAddress: server.requestIP(req)?.address,
8473
+ trustProxy: resolveTrustProxy(),
8474
+ trustedProxies: trustedProxiesFromEnv()
8475
+ }) ?? "unknown";
8182
8476
  }
8183
8477
  function checkRateLimit(ip) {
8184
8478
  const now = Date.now();
@@ -8297,6 +8591,7 @@ var init_serve = __esm(() => {
8297
8591
  init_version();
8298
8592
  init_v1();
8299
8593
  init_openapi();
8594
+ init_client_ip();
8300
8595
  SECURITY_HEADERS = {
8301
8596
  "X-Content-Type-Options": "nosniff",
8302
8597
  "X-Frame-Options": "DENY",
@@ -8345,32 +8640,19 @@ Commands:
8345
8640
  migrate Apply the PostgreSQL schema, then exit. Idempotent.
8346
8641
 
8347
8642
  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`);
8643
+ HASNA_RECORDINGS_DATABASE_URL PostgreSQL DSN (present -> postgresql backend, else sqlite)
8644
+ HASNA_RECORDINGS_MIGRATE_DATABASE_URL Optional MIGRATION-role DSN for migrate (two-role deploy;
8645
+ falls back to the runtime DSN when unset)
8646
+ HASNA_RECORDINGS_API_SIGNING_KEY HMAC signing secret for API-key auth`);
8350
8647
  }
8351
8648
  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)");
8649
+ const { closeCloud: closeCloud2, resolveMigrationDatabaseUrl: resolveMigrationDatabaseUrl2, runRecordedMigration: runRecordedMigration2 } = await Promise.resolve().then(() => (init_cloud(), exports_cloud));
8650
+ if (!resolveMigrationDatabaseUrl2()) {
8651
+ 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
8652
  process.exit(2);
8364
8653
  }
8365
8654
  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
- });
8655
+ await runRecordedMigration2();
8374
8656
  console.log("migrate: done");
8375
8657
  await closeCloud2();
8376
8658
  process.exit(0);