@agent-native/core 0.77.6 → 0.77.8

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 (177) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +29 -0
  3. package/corpus/core/package.json +3 -3
  4. package/corpus/core/src/agent/production-agent.ts +18 -0
  5. package/corpus/core/src/agent/thread-data-builder.ts +21 -2
  6. package/corpus/core/src/cli/create.ts +35 -1
  7. package/corpus/core/src/cli/skills.ts +8 -0
  8. package/corpus/core/src/client/AgentPanel.tsx +57 -25
  9. package/corpus/core/src/client/MultiTabAssistantChat.tsx +31 -1
  10. package/corpus/core/src/client/analytics.ts +24 -2
  11. package/corpus/core/src/client/use-agent-engine-configured.ts +29 -15
  12. package/corpus/core/src/client/use-chat-threads.ts +104 -55
  13. package/corpus/core/src/db/ddl-guard.ts +190 -0
  14. package/corpus/core/src/secrets/storage.ts +36 -2
  15. package/corpus/core/src/server/analytics.ts +27 -3
  16. package/corpus/core/src/settings/store.ts +40 -6
  17. package/corpus/core/src/sharing/access.ts +35 -13
  18. package/corpus/core/src/sharing/actions/share-resource.ts +32 -9
  19. package/corpus/core/src/sharing/actions/unshare-resource.ts +25 -2
  20. package/corpus/core/src/sharing/registry.ts +9 -0
  21. package/corpus/core/src/vite/client.ts +3 -0
  22. package/corpus/templates/analytics/actions/data-source-status.ts +25 -14
  23. package/corpus/templates/analytics/app/components/layout/NewAnalysisDialog.tsx +2 -2
  24. package/corpus/templates/analytics/app/lib/data-source-status.ts +47 -6
  25. package/corpus/templates/analytics/app/lib/data-sources.ts +14 -3
  26. package/corpus/templates/analytics/app/pages/DataSources.tsx +24 -5
  27. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +15 -43
  28. package/corpus/templates/analytics/changelog/2026-06-25-fixed-hubspot-data-source-status-so-private-app-tokens-legac.md +6 -0
  29. package/corpus/templates/analytics/netlify.toml +1 -0
  30. package/corpus/templates/analytics/server/lib/credential-keys.ts +3 -1
  31. package/corpus/templates/analytics/server/routes/api/test-connection.post.ts +12 -3
  32. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +5 -2
  33. package/corpus/templates/assets/changelog/2026-06-25-create-now-returns-from-agent-settings-to-the-asset-chat-eve.md +6 -0
  34. package/corpus/templates/assets/netlify.toml +1 -0
  35. package/corpus/templates/brain/netlify.toml +1 -0
  36. package/corpus/templates/calendar/app/i18n-data.ts +12 -0
  37. package/corpus/templates/calendar/app/lib/clipboard.ts +58 -0
  38. package/corpus/templates/calendar/app/pages/AvailabilitySettings.tsx +15 -9
  39. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +6 -2
  40. package/corpus/templates/calendar/changelog/2026-06-25-booking-link-copy-buttons-no-longer-fail-when-clipboard-perm.md +6 -0
  41. package/corpus/templates/calendar/netlify.toml +1 -0
  42. package/corpus/templates/chat/netlify.toml +1 -0
  43. package/corpus/templates/clips/AGENTS.md +9 -7
  44. package/corpus/templates/clips/actions/add-vocabulary-term.ts +2 -2
  45. package/corpus/templates/clips/actions/archive-recording.ts +5 -2
  46. package/corpus/templates/clips/actions/clear-edits.ts +5 -2
  47. package/corpus/templates/clips/actions/create-folder.ts +7 -2
  48. package/corpus/templates/clips/actions/delete-folder.ts +7 -4
  49. package/corpus/templates/clips/actions/delete-recording-permanent.ts +5 -2
  50. package/corpus/templates/clips/actions/finalize-recording.ts +5 -2
  51. package/corpus/templates/clips/actions/import-loom-recording.ts +2 -1
  52. package/corpus/templates/clips/actions/list-notifications.ts +9 -5
  53. package/corpus/templates/clips/actions/list-organization-state.ts +2 -1
  54. package/corpus/templates/clips/actions/list-recordings.ts +4 -1
  55. package/corpus/templates/clips/actions/move-recording.ts +5 -1
  56. package/corpus/templates/clips/actions/rename-folder.ts +6 -3
  57. package/corpus/templates/clips/actions/request-transcript.ts +5 -2
  58. package/corpus/templates/clips/actions/restore-recording.ts +5 -2
  59. package/corpus/templates/clips/actions/save-browser-diagnostics.ts +21 -8
  60. package/corpus/templates/clips/actions/set-chapters.ts +5 -2
  61. package/corpus/templates/clips/actions/set-thumbnail.ts +5 -2
  62. package/corpus/templates/clips/actions/split-recording.ts +5 -2
  63. package/corpus/templates/clips/actions/stitch-recordings.ts +6 -2
  64. package/corpus/templates/clips/actions/trash-recording.ts +5 -2
  65. package/corpus/templates/clips/actions/undo-edit.ts +5 -2
  66. package/corpus/templates/clips/actions/view-screen.ts +2 -1
  67. package/corpus/templates/clips/app/components/library/recording-card.tsx +24 -7
  68. package/corpus/templates/clips/app/lib/browser-diagnostics-capture.ts +5 -1
  69. package/corpus/templates/clips/changelog/2026-06-25-chrome-extension-recordings-now-upload-large-capture-bursts.md +6 -0
  70. package/corpus/templates/clips/changelog/2026-06-25-console-diagnostics-keep-more-useful-non-secret-details-when.md +6 -0
  71. package/corpus/templates/clips/changelog/2026-06-25-fixed-owner-recognition-so-clips-created-from-desktop-and-we.md +6 -0
  72. package/corpus/templates/clips/chrome-extension/package.json +3 -0
  73. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  74. package/corpus/templates/clips/chrome-extension/src/background.ts +85 -9
  75. package/corpus/templates/clips/chrome-extension/src/content-script.ts +55 -0
  76. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +79 -3
  77. package/corpus/templates/clips/chrome-extension/src/options.ts +10 -0
  78. package/corpus/templates/clips/chrome-extension/src/overlay.ts +6 -0
  79. package/corpus/templates/clips/chrome-extension/src/permission.ts +8 -1
  80. package/corpus/templates/clips/chrome-extension/src/popup.ts +65 -39
  81. package/corpus/templates/clips/chrome-extension/src/sentry.ts +206 -0
  82. package/corpus/templates/clips/chrome-extension/vite.config.ts +47 -0
  83. package/corpus/templates/clips/netlify.toml +1 -0
  84. package/corpus/templates/clips/server/db/index.ts +1 -0
  85. package/corpus/templates/clips/server/lib/google-calendar-oauth.ts +35 -23
  86. package/corpus/templates/clips/server/lib/public-agent-context.ts +8 -1
  87. package/corpus/templates/clips/server/lib/recordings.ts +18 -2
  88. package/corpus/templates/clips/server/lib/slack-oauth.ts +9 -6
  89. package/corpus/templates/clips/server/plugins/onboarding-calendar.ts +6 -2
  90. package/corpus/templates/clips/server/routes/api/public-recording.get.ts +2 -2
  91. package/corpus/templates/clips/server/routes/api/recordings/[recordingId]/thumbnail.post.ts +5 -2
  92. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +5 -2
  93. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +5 -2
  94. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +5 -2
  95. package/corpus/templates/clips/shared/browser-diagnostics.ts +14 -4
  96. package/corpus/templates/content/netlify.toml +1 -0
  97. package/corpus/templates/design/netlify.toml +1 -0
  98. package/corpus/templates/dispatch/netlify.toml +1 -0
  99. package/corpus/templates/forms/netlify.toml +1 -0
  100. package/corpus/templates/macros/netlify.toml +1 -0
  101. package/corpus/templates/mail/app/components/email/EmailList.tsx +7 -3
  102. package/corpus/templates/mail/app/components/email/EmailThread.tsx +4 -1
  103. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +10 -2
  104. package/corpus/templates/mail/app/hooks/use-compose-state.ts +15 -5
  105. package/corpus/templates/mail/app/hooks/use-google-auth.ts +47 -9
  106. package/corpus/templates/mail/changelog/2026-06-25-account-avatars-stay-stable-while-mail-refreshes-google-acco.md +6 -0
  107. package/corpus/templates/mail/changelog/2026-06-25-mail-background-draft-saves-and-thread-prefetches-no-longer.md +6 -0
  108. package/corpus/templates/mail/netlify.toml +1 -0
  109. package/corpus/templates/plan/.agents/skills/visual-plan/references/canvas.md +8 -0
  110. package/corpus/templates/plan/netlify.toml +1 -0
  111. package/corpus/templates/slides/netlify.toml +1 -0
  112. package/corpus/templates/videos/netlify.toml +1 -0
  113. package/dist/agent/production-agent.d.ts.map +1 -1
  114. package/dist/agent/production-agent.js +18 -0
  115. package/dist/agent/production-agent.js.map +1 -1
  116. package/dist/agent/thread-data-builder.d.ts.map +1 -1
  117. package/dist/agent/thread-data-builder.js +23 -2
  118. package/dist/agent/thread-data-builder.js.map +1 -1
  119. package/dist/cli/create.d.ts.map +1 -1
  120. package/dist/cli/create.js +28 -1
  121. package/dist/cli/create.js.map +1 -1
  122. package/dist/cli/skills.d.ts +1 -1
  123. package/dist/cli/skills.d.ts.map +1 -1
  124. package/dist/cli/skills.js +8 -0
  125. package/dist/cli/skills.js.map +1 -1
  126. package/dist/client/AgentPanel.d.ts +8 -2
  127. package/dist/client/AgentPanel.d.ts.map +1 -1
  128. package/dist/client/AgentPanel.js +29 -15
  129. package/dist/client/AgentPanel.js.map +1 -1
  130. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  131. package/dist/client/MultiTabAssistantChat.js +4 -4
  132. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  133. package/dist/client/analytics.d.ts.map +1 -1
  134. package/dist/client/analytics.js +22 -2
  135. package/dist/client/analytics.js.map +1 -1
  136. package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
  137. package/dist/client/use-agent-engine-configured.js +23 -13
  138. package/dist/client/use-agent-engine-configured.js.map +1 -1
  139. package/dist/client/use-chat-threads.d.ts +4 -0
  140. package/dist/client/use-chat-threads.d.ts.map +1 -1
  141. package/dist/client/use-chat-threads.js +49 -4
  142. package/dist/client/use-chat-threads.js.map +1 -1
  143. package/dist/collab/routes.d.ts +2 -2
  144. package/dist/db/ddl-guard.d.ts +88 -0
  145. package/dist/db/ddl-guard.d.ts.map +1 -0
  146. package/dist/db/ddl-guard.js +180 -0
  147. package/dist/db/ddl-guard.js.map +1 -0
  148. package/dist/notifications/routes.d.ts +1 -1
  149. package/dist/observability/routes.d.ts +5 -5
  150. package/dist/progress/routes.d.ts +1 -1
  151. package/dist/resources/handlers.d.ts +3 -3
  152. package/dist/secrets/storage.d.ts.map +1 -1
  153. package/dist/secrets/storage.js +26 -2
  154. package/dist/secrets/storage.js.map +1 -1
  155. package/dist/server/agent-engine-api-key-route.d.ts +2 -2
  156. package/dist/server/analytics.d.ts +4 -0
  157. package/dist/server/analytics.d.ts.map +1 -1
  158. package/dist/server/analytics.js +19 -3
  159. package/dist/server/analytics.js.map +1 -1
  160. package/dist/server/transcribe-voice.d.ts +1 -1
  161. package/dist/settings/store.d.ts.map +1 -1
  162. package/dist/settings/store.js +32 -6
  163. package/dist/settings/store.js.map +1 -1
  164. package/dist/sharing/access.d.ts.map +1 -1
  165. package/dist/sharing/access.js +26 -12
  166. package/dist/sharing/access.js.map +1 -1
  167. package/dist/sharing/actions/share-resource.js +20 -9
  168. package/dist/sharing/actions/share-resource.js.map +1 -1
  169. package/dist/sharing/actions/unshare-resource.js +13 -2
  170. package/dist/sharing/actions/unshare-resource.js.map +1 -1
  171. package/dist/sharing/registry.d.ts +9 -0
  172. package/dist/sharing/registry.d.ts.map +1 -1
  173. package/dist/sharing/registry.js.map +1 -1
  174. package/dist/vite/client.d.ts.map +1 -1
  175. package/dist/vite/client.js +1 -0
  176. package/dist/vite/client.js.map +1 -1
  177. package/package.json +3 -3
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Guards for on-demand `ensureTable()` DDL so the common already-migrated path
3
+ * takes NO `ACCESS EXCLUSIVE` lock on Postgres.
4
+ *
5
+ * Lives in its own module (like `./widen-columns.js`) so stores can import it
6
+ * without every `vi.mock("../db/client.js")` test needing to stub it: the
7
+ * helpers resolve `isPostgres()` / `getDbExec()` through `client.js`, so a test
8
+ * that mocks the client to SQLite (`isPostgres: () => false`) makes the
9
+ * Postgres-only existence checks no-ops automatically.
10
+ *
11
+ * ## Why
12
+ *
13
+ * `ensureTable()` runs once per process on first DB touch. In a long-lived Node
14
+ * server the cost is paid once and is invisible. In a Netlify `-background`
15
+ * function the process is fresh, so this is the FIRST touch — and the
16
+ * `CREATE TABLE`/`ALTER TABLE ... ADD COLUMN` DDL it issues takes an
17
+ * `ACCESS EXCLUSIVE` lock on the shared Neon Postgres database. Behind a
18
+ * concurrent connection that already holds a conflicting lock, that DDL can
19
+ * block ~indefinitely (observed >16s hangs in the bg worker vs ~1s inline).
20
+ *
21
+ * The tables/columns are essentially always already present in production, so
22
+ * the DDL is redundant in the hot path. These helpers let a store cheaply check
23
+ * `information_schema` first and only issue DDL when something is actually
24
+ * missing — and when DDL must run, wrap it in a short `lock_timeout` so a
25
+ * contended lock fails fast instead of hanging.
26
+ *
27
+ * All of this is Postgres-only behaviour gated on `isPostgres()`. On SQLite
28
+ * (local dev) there is no such lock problem, so callers keep their existing
29
+ * behaviour there.
30
+ */
31
+ import { isPostgres, getDbExec } from "./client.js";
32
+ const PLAIN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
33
+ /**
34
+ * True when running against Postgres AND the given table already exists in the
35
+ * `public` schema. Returns `false` on SQLite (callers gate their own behaviour
36
+ * there), for invalid identifiers, or when `information_schema` is unreadable —
37
+ * the conservative answer "not known to exist" makes the caller fall through to
38
+ * its idempotent `CREATE TABLE IF NOT EXISTS`, preserving today's behaviour.
39
+ *
40
+ * This is a plain read (no lock), so it never blocks on an `ACCESS EXCLUSIVE`
41
+ * lock the way `CREATE`/`ALTER` would.
42
+ */
43
+ export async function pgTableExists(table, injectedClient) {
44
+ if (!isPostgres() || !PLAIN_IDENTIFIER.test(table))
45
+ return false;
46
+ const client = injectedClient ?? getDbExec();
47
+ try {
48
+ const { rows } = await client.execute({
49
+ sql: `SELECT 1 FROM information_schema.tables
50
+ WHERE table_schema = 'public' AND table_name = ? LIMIT 1`,
51
+ args: [table],
52
+ });
53
+ return rows.length > 0;
54
+ }
55
+ catch {
56
+ // information_schema unreadable (permissions / non-standard backend) —
57
+ // report "unknown" so the caller falls back to IF NOT EXISTS.
58
+ return false;
59
+ }
60
+ }
61
+ /**
62
+ * True when running against Postgres AND the given column already exists on the
63
+ * given table in the `public` schema. Returns `false` on SQLite, for invalid
64
+ * identifiers, or when `information_schema` is unreadable.
65
+ *
66
+ * Plain read — no lock taken.
67
+ */
68
+ export async function pgColumnExists(table, column, injectedClient) {
69
+ if (!isPostgres())
70
+ return false;
71
+ if (!PLAIN_IDENTIFIER.test(table) || !PLAIN_IDENTIFIER.test(column)) {
72
+ return false;
73
+ }
74
+ const client = injectedClient ?? getDbExec();
75
+ try {
76
+ const { rows } = await client.execute({
77
+ sql: `SELECT 1 FROM information_schema.columns
78
+ WHERE table_schema = 'public' AND table_name = ? AND column_name = ?
79
+ LIMIT 1`,
80
+ args: [table, column],
81
+ });
82
+ return rows.length > 0;
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ }
88
+ /**
89
+ * True when running against Postgres AND an index with the given name already
90
+ * exists in the `public` schema. Returns `false` on SQLite, for invalid
91
+ * identifiers, or when `pg_indexes` is unreadable.
92
+ *
93
+ * `CREATE INDEX` (without CONCURRENTLY) takes a `SHARE` lock that blocks
94
+ * writes, so on a fresh background-worker process behind a concurrent
95
+ * connection this can hang just like a `CREATE`/`ALTER` would; checking first
96
+ * skips the lock on the already-migrated hot path.
97
+ */
98
+ export async function pgIndexExists(indexName, injectedClient) {
99
+ if (!isPostgres() || !PLAIN_IDENTIFIER.test(indexName))
100
+ return false;
101
+ const client = injectedClient ?? getDbExec();
102
+ try {
103
+ const { rows } = await client.execute({
104
+ sql: `SELECT 1 FROM pg_indexes
105
+ WHERE schemaname = 'public' AND indexname = ? LIMIT 1`,
106
+ args: [indexName],
107
+ });
108
+ return rows.length > 0;
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ }
114
+ /** True when an error looks like a Postgres `lock_timeout` (SQLSTATE 55P03). */
115
+ export function isLockTimeoutError(err) {
116
+ const anyErr = err;
117
+ if (anyErr?.code === "55P03")
118
+ return true;
119
+ const msg = String(anyErr?.message ?? anyErr ?? "");
120
+ return /lock[_ ]?timeout|canceling statement due to lock timeout/i.test(msg);
121
+ }
122
+ /**
123
+ * Run a DDL statement that MUST execute (the schema is actually missing), with
124
+ * a short `lock_timeout` so a contended `ACCESS EXCLUSIVE` lock fails fast
125
+ * instead of hanging the whole process.
126
+ *
127
+ * Postgres path: wrap the DDL in an explicit transaction and set
128
+ * `SET LOCAL lock_timeout` so the timeout is scoped to THIS transaction only
129
+ * and reset automatically on COMMIT/ROLLBACK — it never leaks onto the pooled
130
+ * (session-reused) connection the way a bare `SET lock_timeout` would. If the
131
+ * DbExec has no `transaction` (shouldn't happen for Postgres, but defensively),
132
+ * fall back to a session `SET` + `RESET` in a finally. A lock-timeout error is
133
+ * swallowed: the table/column is virtually always already correct by the time a
134
+ * contended boot retries, and the caller's memoization should still resolve so
135
+ * the path isn't retried in a tight loop. Any non-lock-timeout error rethrows.
136
+ *
137
+ * SQLite path: no lock problem — just run the DDL directly.
138
+ *
139
+ * @returns `true` if the DDL ran to completion, `false` if it was skipped due to
140
+ * a lock-timeout (so the caller can decide whether to log).
141
+ */
142
+ export async function runGuardedDdl(ddl, options = {}) {
143
+ const client = options.injectedClient ?? getDbExec();
144
+ if (!isPostgres()) {
145
+ await client.execute(ddl);
146
+ return true;
147
+ }
148
+ const lockTimeout = options.lockTimeout ?? "3s";
149
+ try {
150
+ if (typeof client.transaction === "function") {
151
+ await client.transaction(async (tx) => {
152
+ // SET LOCAL is transaction-scoped: it reverts on COMMIT/ROLLBACK and
153
+ // never persists on the pooled connection.
154
+ await tx.execute(`SET LOCAL lock_timeout = '${lockTimeout}'`);
155
+ await tx.execute(ddl);
156
+ });
157
+ }
158
+ else {
159
+ // Defensive fallback: no transaction support. Use a session SET and
160
+ // guarantee a RESET so the timeout never leaks onto a reused connection.
161
+ try {
162
+ await client.execute(`SET lock_timeout = '${lockTimeout}'`);
163
+ await client.execute(ddl);
164
+ }
165
+ finally {
166
+ await client.execute(`RESET lock_timeout`).catch(() => { });
167
+ }
168
+ }
169
+ return true;
170
+ }
171
+ catch (err) {
172
+ if (isLockTimeoutError(err)) {
173
+ // Contended lock — the schema is virtually always already correct by now.
174
+ // Proceed; the caller's memoization still resolves so we don't loop.
175
+ return false;
176
+ }
177
+ throw err;
178
+ }
179
+ }
180
+ //# sourceMappingURL=ddl-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ddl-guard.js","sourceRoot":"","sources":["../../src/db/ddl-guard.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Guards for on-demand `ensureTable()` DDL so the common already-migrated path\n * takes NO `ACCESS EXCLUSIVE` lock on Postgres.\n *\n * Lives in its own module (like `./widen-columns.js`) so stores can import it\n * without every `vi.mock(\"../db/client.js\")` test needing to stub it: the\n * helpers resolve `isPostgres()` / `getDbExec()` through `client.js`, so a test\n * that mocks the client to SQLite (`isPostgres: () => false`) makes the\n * Postgres-only existence checks no-ops automatically.\n *\n * ## Why\n *\n * `ensureTable()` runs once per process on first DB touch. In a long-lived Node\n * server the cost is paid once and is invisible. In a Netlify `-background`\n * function the process is fresh, so this is the FIRST touch — and the\n * `CREATE TABLE`/`ALTER TABLE ... ADD COLUMN` DDL it issues takes an\n * `ACCESS EXCLUSIVE` lock on the shared Neon Postgres database. Behind a\n * concurrent connection that already holds a conflicting lock, that DDL can\n * block ~indefinitely (observed >16s hangs in the bg worker vs ~1s inline).\n *\n * The tables/columns are essentially always already present in production, so\n * the DDL is redundant in the hot path. These helpers let a store cheaply check\n * `information_schema` first and only issue DDL when something is actually\n * missing — and when DDL must run, wrap it in a short `lock_timeout` so a\n * contended lock fails fast instead of hanging.\n *\n * All of this is Postgres-only behaviour gated on `isPostgres()`. On SQLite\n * (local dev) there is no such lock problem, so callers keep their existing\n * behaviour there.\n */\n\nimport { isPostgres, getDbExec, type DbExec } from \"./client.js\";\n\nconst PLAIN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;\n\n/**\n * True when running against Postgres AND the given table already exists in the\n * `public` schema. Returns `false` on SQLite (callers gate their own behaviour\n * there), for invalid identifiers, or when `information_schema` is unreadable —\n * the conservative answer \"not known to exist\" makes the caller fall through to\n * its idempotent `CREATE TABLE IF NOT EXISTS`, preserving today's behaviour.\n *\n * This is a plain read (no lock), so it never blocks on an `ACCESS EXCLUSIVE`\n * lock the way `CREATE`/`ALTER` would.\n */\nexport async function pgTableExists(\n table: string,\n injectedClient?: DbExec,\n): Promise<boolean> {\n if (!isPostgres() || !PLAIN_IDENTIFIER.test(table)) return false;\n const client = injectedClient ?? getDbExec();\n try {\n const { rows } = await client.execute({\n sql: `SELECT 1 FROM information_schema.tables\n WHERE table_schema = 'public' AND table_name = ? LIMIT 1`,\n args: [table],\n });\n return rows.length > 0;\n } catch {\n // information_schema unreadable (permissions / non-standard backend) —\n // report \"unknown\" so the caller falls back to IF NOT EXISTS.\n return false;\n }\n}\n\n/**\n * True when running against Postgres AND the given column already exists on the\n * given table in the `public` schema. Returns `false` on SQLite, for invalid\n * identifiers, or when `information_schema` is unreadable.\n *\n * Plain read — no lock taken.\n */\nexport async function pgColumnExists(\n table: string,\n column: string,\n injectedClient?: DbExec,\n): Promise<boolean> {\n if (!isPostgres()) return false;\n if (!PLAIN_IDENTIFIER.test(table) || !PLAIN_IDENTIFIER.test(column)) {\n return false;\n }\n const client = injectedClient ?? getDbExec();\n try {\n const { rows } = await client.execute({\n sql: `SELECT 1 FROM information_schema.columns\n WHERE table_schema = 'public' AND table_name = ? AND column_name = ?\n LIMIT 1`,\n args: [table, column],\n });\n return rows.length > 0;\n } catch {\n return false;\n }\n}\n\n/**\n * True when running against Postgres AND an index with the given name already\n * exists in the `public` schema. Returns `false` on SQLite, for invalid\n * identifiers, or when `pg_indexes` is unreadable.\n *\n * `CREATE INDEX` (without CONCURRENTLY) takes a `SHARE` lock that blocks\n * writes, so on a fresh background-worker process behind a concurrent\n * connection this can hang just like a `CREATE`/`ALTER` would; checking first\n * skips the lock on the already-migrated hot path.\n */\nexport async function pgIndexExists(\n indexName: string,\n injectedClient?: DbExec,\n): Promise<boolean> {\n if (!isPostgres() || !PLAIN_IDENTIFIER.test(indexName)) return false;\n const client = injectedClient ?? getDbExec();\n try {\n const { rows } = await client.execute({\n sql: `SELECT 1 FROM pg_indexes\n WHERE schemaname = 'public' AND indexname = ? LIMIT 1`,\n args: [indexName],\n });\n return rows.length > 0;\n } catch {\n return false;\n }\n}\n\n/** True when an error looks like a Postgres `lock_timeout` (SQLSTATE 55P03). */\nexport function isLockTimeoutError(err: unknown): boolean {\n const anyErr = err as { code?: unknown; message?: unknown } | null;\n if (anyErr?.code === \"55P03\") return true;\n const msg = String(anyErr?.message ?? anyErr ?? \"\");\n return /lock[_ ]?timeout|canceling statement due to lock timeout/i.test(msg);\n}\n\n/**\n * Run a DDL statement that MUST execute (the schema is actually missing), with\n * a short `lock_timeout` so a contended `ACCESS EXCLUSIVE` lock fails fast\n * instead of hanging the whole process.\n *\n * Postgres path: wrap the DDL in an explicit transaction and set\n * `SET LOCAL lock_timeout` so the timeout is scoped to THIS transaction only\n * and reset automatically on COMMIT/ROLLBACK — it never leaks onto the pooled\n * (session-reused) connection the way a bare `SET lock_timeout` would. If the\n * DbExec has no `transaction` (shouldn't happen for Postgres, but defensively),\n * fall back to a session `SET` + `RESET` in a finally. A lock-timeout error is\n * swallowed: the table/column is virtually always already correct by the time a\n * contended boot retries, and the caller's memoization should still resolve so\n * the path isn't retried in a tight loop. Any non-lock-timeout error rethrows.\n *\n * SQLite path: no lock problem — just run the DDL directly.\n *\n * @returns `true` if the DDL ran to completion, `false` if it was skipped due to\n * a lock-timeout (so the caller can decide whether to log).\n */\nexport async function runGuardedDdl(\n ddl: string,\n options: { lockTimeout?: string; injectedClient?: DbExec } = {},\n): Promise<boolean> {\n const client = options.injectedClient ?? getDbExec();\n if (!isPostgres()) {\n await client.execute(ddl);\n return true;\n }\n\n const lockTimeout = options.lockTimeout ?? \"3s\";\n try {\n if (typeof client.transaction === \"function\") {\n await client.transaction(async (tx) => {\n // SET LOCAL is transaction-scoped: it reverts on COMMIT/ROLLBACK and\n // never persists on the pooled connection.\n await tx.execute(`SET LOCAL lock_timeout = '${lockTimeout}'`);\n await tx.execute(ddl);\n });\n } else {\n // Defensive fallback: no transaction support. Use a session SET and\n // guarantee a RESET so the timeout never leaks onto a reused connection.\n try {\n await client.execute(`SET lock_timeout = '${lockTimeout}'`);\n await client.execute(ddl);\n } finally {\n await client.execute(`RESET lock_timeout`).catch(() => {});\n }\n }\n return true;\n } catch (err) {\n if (isLockTimeoutError(err)) {\n // Contended lock — the schema is virtually always already correct by now.\n // Proceed; the caller's memoization still resolves so we don't loop.\n return false;\n }\n throw err;\n }\n}\n"]}
@@ -28,7 +28,7 @@ export declare function createNotificationsHandler(): import("h3").EventHandlerW
28
28
  } | {
29
29
  count?: undefined;
30
30
  updated?: undefined;
31
- error?: undefined;
32
31
  ok: boolean;
32
+ error?: undefined;
33
33
  }>>;
34
34
  //# sourceMappingURL=routes.d.ts.map
@@ -41,28 +41,28 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
41
41
  thumbsUpRate: number;
42
42
  avgEvalScore: number;
43
43
  } | {
44
- error?: undefined;
45
- ok?: undefined;
46
44
  summary: import("./types.js").TraceSummary;
47
45
  spans: import("./types.js").TraceSpan[];
48
46
  id?: undefined;
49
- } | {
50
47
  error?: undefined;
51
48
  ok?: undefined;
49
+ } | {
52
50
  summary?: undefined;
53
51
  spans?: undefined;
54
52
  id: string;
55
- } | {
53
+ error?: undefined;
56
54
  ok?: undefined;
55
+ } | {
57
56
  summary?: undefined;
58
57
  spans?: undefined;
59
58
  id?: undefined;
60
59
  error: any;
60
+ ok?: undefined;
61
61
  } | {
62
- error?: undefined;
63
62
  summary?: undefined;
64
63
  spans?: undefined;
65
64
  id?: undefined;
65
+ error?: undefined;
66
66
  ok: boolean;
67
67
  }>>;
68
68
  //# sourceMappingURL=routes.d.ts.map
@@ -15,7 +15,7 @@ export declare function createProgressHandler(): import("h3").EventHandlerWithFe
15
15
  error: string;
16
16
  ok?: undefined;
17
17
  } | {
18
- ok: boolean;
19
18
  error?: undefined;
19
+ ok: boolean;
20
20
  }>>;
21
21
  //# sourceMappingURL=routes.d.ts.map
@@ -49,11 +49,11 @@ export declare function handleUpdateResource(event: any): Promise<import("./stor
49
49
  }>;
50
50
  /** DELETE /_agent-native/resources/:id — delete a resource */
51
51
  export declare function handleDeleteResource(event: any): Promise<{
52
- error: string;
53
52
  ok?: undefined;
53
+ error: string;
54
54
  } | {
55
- ok: boolean;
56
55
  error?: undefined;
56
+ ok: boolean;
57
57
  }>;
58
58
  /** POST /_agent-native/resources/upload — upload a file as a resource */
59
59
  export declare function handleUploadResource(event: any): Promise<import("./store.js").Resource | {
@@ -73,9 +73,9 @@ export declare function handleUploadResource(event: any): Promise<import("./stor
73
73
  runId: string | null;
74
74
  expiresAt: number | null;
75
75
  metadata: string | null;
76
+ error?: undefined;
76
77
  url: string;
77
78
  provider: string;
78
- error?: undefined;
79
79
  }>;
80
80
  export {};
81
81
  //# sourceMappingURL=handlers.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../src/secrets/storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AASH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAiDjD;;;GAGG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAI3C;AAMD,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAgB,SAAQ,SAAS;IAChD,KAAK,EAAE,MAAM,CAAC;IACd,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8DAA8D;IAC9D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CA0C3E;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,SAAS,GACb,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAqBlC;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,SAAS,GACb,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAItD;AAED,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,YAAY,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,SAAS,GACb,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CA2B5B;AAED;;;;GAIG;AACH,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,UAAU,EAAE,CAAC,CA0BvB;AAeD,wBAAsB,eAAe,CAAC,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,CAStE"}
1
+ {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../src/secrets/storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAcH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AA8EjD;;;GAGG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAI3C;AAMD,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAgB,SAAQ,SAAS;IAChD,KAAK,EAAE,MAAM,CAAC;IACd,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8DAA8D;IAC9D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CA0C3E;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,SAAS,GACb,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAqBlC;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,SAAS,GACb,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAItD;AAED,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,YAAY,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,SAAS,GACb,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CA2B5B;AAED;;;;GAIG;AACH,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,UAAU,EAAE,CAAC,CA0BvB;AAeD,wBAAsB,eAAe,CAAC,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,CAStE"}
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { randomUUID } from "node:crypto";
15
15
  import { getDbExec, isPostgres } from "../db/client.js";
16
+ import { pgColumnExists, pgTableExists, runGuardedDdl, } from "../db/ddl-guard.js";
16
17
  import { encryptSecretValue as encryptValue, decryptSecretValue as decryptValue, } from "./crypto.js";
17
18
  import { APP_SECRETS_CREATE_SQL } from "./schema.js";
18
19
  // ---------------------------------------------------------------------------
@@ -25,10 +26,33 @@ async function ensureTable() {
25
26
  const client = getDbExec();
26
27
  // Postgres version of the CREATE TABLE — the generic `INTEGER` maps to
27
28
  // BIGINT on Postgres, which we need for millisecond timestamps.
28
- const sql = isPostgres()
29
+ const createSql = isPostgres()
29
30
  ? APP_SECRETS_CREATE_SQL.replace(/\bINTEGER\b/g, "BIGINT")
30
31
  : APP_SECRETS_CREATE_SQL;
31
- await client.execute(sql);
32
+ if (isPostgres()) {
33
+ // Hot path: in production the table and both additive columns are
34
+ // virtually always already present. Issuing `CREATE`/`ALTER` would
35
+ // still take an ACCESS EXCLUSIVE lock — which, in a fresh background
36
+ // worker process behind a concurrent connection on the shared Neon DB,
37
+ // can block ~indefinitely. So check `information_schema` first (a plain
38
+ // read, no lock) and run DDL ONLY for what is actually missing. When
39
+ // DDL must run, `runGuardedDdl` wraps it in a transaction-scoped
40
+ // `lock_timeout` so a contended lock fails fast instead of hanging.
41
+ if (!(await pgTableExists("app_secrets"))) {
42
+ await runGuardedDdl(createSql);
43
+ }
44
+ if (!(await pgColumnExists("app_secrets", "description"))) {
45
+ await runGuardedDdl(`ALTER TABLE app_secrets ADD COLUMN IF NOT EXISTS description TEXT`);
46
+ }
47
+ if (!(await pgColumnExists("app_secrets", "url_allowlist"))) {
48
+ await runGuardedDdl(`ALTER TABLE app_secrets ADD COLUMN IF NOT EXISTS url_allowlist TEXT`);
49
+ }
50
+ return;
51
+ }
52
+ // SQLite (local dev): no ACCESS EXCLUSIVE lock problem, keep the original
53
+ // create-then-additive-alter behaviour. SQLite has no
54
+ // `ADD COLUMN IF NOT EXISTS`, so the ALTERs stay wrapped in try/catch.
55
+ await client.execute(createSql);
32
56
  // Additive migration: description column (for ad-hoc keys)
33
57
  try {
34
58
  await client.execute(`ALTER TABLE app_secrets ADD COLUMN description TEXT`);
@@ -1 +1 @@
1
- {"version":3,"file":"storage.js","sourceRoot":"","sources":["../../src/secrets/storage.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Storage layer for the framework secrets registry.\n *\n * Values are encrypted at rest with AES-256-GCM. The encryption key is\n * derived from `SECRETS_ENCRYPTION_KEY` (preferred) or the existing\n * `BETTER_AUTH_SECRET` env var (fallback so templates don't need a second\n * secret during development). If neither is set in production we fall back\n * to a machine-local key derived from the cwd — the secret is still only\n * readable on this machine, but consider setting `SECRETS_ENCRYPTION_KEY`\n * for a stable, rotatable key.\n *\n * Secret values are NEVER logged and NEVER returned from any route handler.\n */\n\nimport { randomUUID } from \"node:crypto\";\n\nimport { getDbExec, isPostgres } from \"../db/client.js\";\nimport {\n encryptSecretValue as encryptValue,\n decryptSecretValue as decryptValue,\n} from \"./crypto.js\";\nimport type { SecretScope } from \"./register.js\";\nimport { APP_SECRETS_CREATE_SQL } from \"./schema.js\";\n\n// ---------------------------------------------------------------------------\n// Table bootstrap\n// ---------------------------------------------------------------------------\n\nlet _initPromise: Promise<void> | undefined;\n\nasync function ensureTable(): Promise<void> {\n if (!_initPromise) {\n _initPromise = (async () => {\n const client = getDbExec();\n // Postgres version of the CREATE TABLE — the generic `INTEGER` maps to\n // BIGINT on Postgres, which we need for millisecond timestamps.\n const sql = isPostgres()\n ? APP_SECRETS_CREATE_SQL.replace(/\\bINTEGER\\b/g, \"BIGINT\")\n : APP_SECRETS_CREATE_SQL;\n await client.execute(sql);\n\n // Additive migration: description column (for ad-hoc keys)\n try {\n await client.execute(\n `ALTER TABLE app_secrets ADD COLUMN description TEXT`,\n );\n } catch {\n // Column already exists — expected\n }\n\n // Additive migration: url_allowlist column\n try {\n await client.execute(\n `ALTER TABLE app_secrets ADD COLUMN url_allowlist TEXT`,\n );\n } catch {\n // Column already exists — expected\n }\n })().catch((err) => {\n _initPromise = undefined;\n throw err;\n });\n }\n return _initPromise;\n}\n\n// ---------------------------------------------------------------------------\n// Encryption — see ./crypto.ts (shared with per-user credentials)\n// ---------------------------------------------------------------------------\n\n/**\n * Return the last 4 characters of a secret, with any leading characters\n * masked. Used to show a preview without leaking the value.\n */\nexport function last4(value: string): string {\n if (!value) return \"\";\n if (value.length <= 4) return \"••••\";\n return \"••••\" + value.slice(-4);\n}\n\n// ---------------------------------------------------------------------------\n// CRUD\n// ---------------------------------------------------------------------------\n\nexport interface SecretRef {\n key: string;\n scope: SecretScope;\n scopeId: string;\n}\n\nexport interface WriteSecretArgs extends SecretRef {\n value: string;\n /** Optional human-readable description (used for ad-hoc keys). */\n description?: string;\n /** Optional JSON-stringified array of allowed URL origins. */\n urlAllowlist?: string;\n}\n\n/**\n * Write (insert or update) a secret. The value is encrypted before being\n * stored — the caller's plaintext is never persisted. Returns the new\n * record's id.\n */\nexport async function writeAppSecret(args: WriteSecretArgs): Promise<string> {\n await ensureTable();\n const { key, value, scope, scopeId, description, urlAllowlist } = args;\n if (!key || !value || !scope || !scopeId) {\n throw new Error(\n \"writeAppSecret: key, value, scope, and scopeId are all required\",\n );\n }\n const client = getDbExec();\n const now = Date.now();\n const encrypted = encryptValue(value);\n\n // Upsert by (scope, scope_id, key). Keep the existing row's id on update so\n // references stay stable.\n const { rows } = await client.execute({\n sql: `SELECT id FROM app_secrets WHERE scope = ? AND scope_id = ? AND key = ?`,\n args: [scope, scopeId, key],\n });\n if (rows.length > 0) {\n const id = rows[0].id as string;\n await client.execute({\n sql: `UPDATE app_secrets SET encrypted_value = ?, description = ?, url_allowlist = ?, updated_at = ? WHERE id = ?`,\n args: [encrypted, description ?? null, urlAllowlist ?? null, now, id],\n });\n return id;\n }\n const id = randomUUID();\n await client.execute({\n sql: `INSERT INTO app_secrets (id, scope, scope_id, key, encrypted_value, description, url_allowlist, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n args: [\n id,\n scope,\n scopeId,\n key,\n encrypted,\n description ?? null,\n urlAllowlist ?? null,\n now,\n now,\n ],\n });\n return id;\n}\n\nexport interface ReadSecretResult {\n value: string;\n last4: string;\n updatedAt: number;\n}\n\n/**\n * Read a secret's plaintext value. Returns null when not found. The caller\n * is responsible for never logging the returned value.\n */\nexport async function readAppSecret(\n ref: SecretRef,\n): Promise<ReadSecretResult | null> {\n await ensureTable();\n const { key, scope, scopeId } = ref;\n const client = getDbExec();\n const { rows } = await client.execute({\n sql: `SELECT encrypted_value, updated_at FROM app_secrets WHERE scope = ? AND scope_id = ? AND key = ? LIMIT 1`,\n args: [scope, scopeId, key],\n });\n if (rows.length === 0) return null;\n try {\n const value = decryptValue(rows[0].encrypted_value as string);\n return {\n value,\n last4: last4(value),\n updatedAt: Number(rows[0].updated_at ?? 0),\n };\n } catch {\n // Decryption failure — key rotated, tampered row, etc. Don't throw up the\n // stack in a way that could leak the ciphertext; just report missing.\n return null;\n }\n}\n\n/**\n * Return just the metadata for a secret (no value). Used by the list route so\n * the UI can show the \"Set\" pill and last-4 without the decrypted value going\n * over the wire.\n */\nexport async function getAppSecretMeta(\n ref: SecretRef,\n): Promise<{ last4: string; updatedAt: number } | null> {\n const result = await readAppSecret(ref);\n if (!result) return null;\n return { last4: result.last4, updatedAt: result.updatedAt };\n}\n\nexport interface SecretMeta {\n key: string;\n scope: SecretScope;\n scopeId: string;\n last4: string;\n description: string | null;\n urlAllowlist: string[] | null;\n createdAt: number;\n updatedAt: number;\n}\n\n/**\n * Read a secret's metadata, including ad-hoc fields (description, allowlist),\n * without ever decrypting or returning the plaintext value. Used by the\n * ad-hoc list route and any UI that wants to render a key tile.\n */\nexport async function readAppSecretMeta(\n ref: SecretRef,\n): Promise<SecretMeta | null> {\n await ensureTable();\n const { key, scope, scopeId } = ref;\n const client = getDbExec();\n const { rows } = await client.execute({\n sql: `SELECT encrypted_value, description, url_allowlist, created_at, updated_at FROM app_secrets WHERE scope = ? AND scope_id = ? AND key = ? LIMIT 1`,\n args: [scope, scopeId, key],\n });\n if (rows.length === 0) return null;\n const row = rows[0];\n let last4Value = \"\";\n try {\n const value = decryptValue(row.encrypted_value as string);\n last4Value = last4(value);\n } catch {\n last4Value = \"\";\n }\n return {\n key,\n scope,\n scopeId,\n last4: last4Value,\n description: (row.description as string | null) ?? null,\n urlAllowlist: parseAllowlist(row.url_allowlist as string | null),\n createdAt: Number(row.created_at ?? 0),\n updatedAt: Number(row.updated_at ?? 0),\n };\n}\n\n/**\n * List all secrets for a given scope. Returns metadata only — values are\n * never decrypted or returned. Used by the ad-hoc list route to surface\n * user-created keys.\n */\nexport async function listAppSecretsForScope(\n scope: SecretScope,\n scopeId: string,\n): Promise<SecretMeta[]> {\n await ensureTable();\n const client = getDbExec();\n const { rows } = await client.execute({\n sql: `SELECT key, encrypted_value, description, url_allowlist, created_at, updated_at FROM app_secrets WHERE scope = ? AND scope_id = ? ORDER BY updated_at DESC`,\n args: [scope, scopeId],\n });\n return rows.map((row) => {\n let last4Value = \"\";\n try {\n const value = decryptValue(row.encrypted_value as string);\n last4Value = last4(value);\n } catch {\n last4Value = \"\";\n }\n return {\n key: row.key as string,\n scope,\n scopeId,\n last4: last4Value,\n description: (row.description as string | null) ?? null,\n urlAllowlist: parseAllowlist(row.url_allowlist as string | null),\n createdAt: Number(row.created_at ?? 0),\n updatedAt: Number(row.updated_at ?? 0),\n };\n });\n}\n\nfunction parseAllowlist(raw: string | null): string[] | null {\n if (!raw) return null;\n try {\n const parsed = JSON.parse(raw);\n if (Array.isArray(parsed) && parsed.every((v) => typeof v === \"string\")) {\n return parsed;\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport async function deleteAppSecret(ref: SecretRef): Promise<boolean> {\n await ensureTable();\n const { key, scope, scopeId } = ref;\n const client = getDbExec();\n const { rowsAffected } = await client.execute({\n sql: `DELETE FROM app_secrets WHERE scope = ? AND scope_id = ? AND key = ?`,\n args: [scope, scopeId, key],\n });\n return rowsAffected > 0;\n}\n"]}
1
+ {"version":3,"file":"storage.js","sourceRoot":"","sources":["../../src/secrets/storage.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Storage layer for the framework secrets registry.\n *\n * Values are encrypted at rest with AES-256-GCM. The encryption key is\n * derived from `SECRETS_ENCRYPTION_KEY` (preferred) or the existing\n * `BETTER_AUTH_SECRET` env var (fallback so templates don't need a second\n * secret during development). If neither is set in production we fall back\n * to a machine-local key derived from the cwd — the secret is still only\n * readable on this machine, but consider setting `SECRETS_ENCRYPTION_KEY`\n * for a stable, rotatable key.\n *\n * Secret values are NEVER logged and NEVER returned from any route handler.\n */\n\nimport { randomUUID } from \"node:crypto\";\n\nimport { getDbExec, isPostgres } from \"../db/client.js\";\nimport {\n pgColumnExists,\n pgTableExists,\n runGuardedDdl,\n} from \"../db/ddl-guard.js\";\nimport {\n encryptSecretValue as encryptValue,\n decryptSecretValue as decryptValue,\n} from \"./crypto.js\";\nimport type { SecretScope } from \"./register.js\";\nimport { APP_SECRETS_CREATE_SQL } from \"./schema.js\";\n\n// ---------------------------------------------------------------------------\n// Table bootstrap\n// ---------------------------------------------------------------------------\n\nlet _initPromise: Promise<void> | undefined;\n\nasync function ensureTable(): Promise<void> {\n if (!_initPromise) {\n _initPromise = (async () => {\n const client = getDbExec();\n // Postgres version of the CREATE TABLE — the generic `INTEGER` maps to\n // BIGINT on Postgres, which we need for millisecond timestamps.\n const createSql = isPostgres()\n ? APP_SECRETS_CREATE_SQL.replace(/\\bINTEGER\\b/g, \"BIGINT\")\n : APP_SECRETS_CREATE_SQL;\n\n if (isPostgres()) {\n // Hot path: in production the table and both additive columns are\n // virtually always already present. Issuing `CREATE`/`ALTER` would\n // still take an ACCESS EXCLUSIVE lock — which, in a fresh background\n // worker process behind a concurrent connection on the shared Neon DB,\n // can block ~indefinitely. So check `information_schema` first (a plain\n // read, no lock) and run DDL ONLY for what is actually missing. When\n // DDL must run, `runGuardedDdl` wraps it in a transaction-scoped\n // `lock_timeout` so a contended lock fails fast instead of hanging.\n if (!(await pgTableExists(\"app_secrets\"))) {\n await runGuardedDdl(createSql);\n }\n if (!(await pgColumnExists(\"app_secrets\", \"description\"))) {\n await runGuardedDdl(\n `ALTER TABLE app_secrets ADD COLUMN IF NOT EXISTS description TEXT`,\n );\n }\n if (!(await pgColumnExists(\"app_secrets\", \"url_allowlist\"))) {\n await runGuardedDdl(\n `ALTER TABLE app_secrets ADD COLUMN IF NOT EXISTS url_allowlist TEXT`,\n );\n }\n return;\n }\n\n // SQLite (local dev): no ACCESS EXCLUSIVE lock problem, keep the original\n // create-then-additive-alter behaviour. SQLite has no\n // `ADD COLUMN IF NOT EXISTS`, so the ALTERs stay wrapped in try/catch.\n await client.execute(createSql);\n\n // Additive migration: description column (for ad-hoc keys)\n try {\n await client.execute(\n `ALTER TABLE app_secrets ADD COLUMN description TEXT`,\n );\n } catch {\n // Column already exists — expected\n }\n\n // Additive migration: url_allowlist column\n try {\n await client.execute(\n `ALTER TABLE app_secrets ADD COLUMN url_allowlist TEXT`,\n );\n } catch {\n // Column already exists — expected\n }\n })().catch((err) => {\n _initPromise = undefined;\n throw err;\n });\n }\n return _initPromise;\n}\n\n// ---------------------------------------------------------------------------\n// Encryption — see ./crypto.ts (shared with per-user credentials)\n// ---------------------------------------------------------------------------\n\n/**\n * Return the last 4 characters of a secret, with any leading characters\n * masked. Used to show a preview without leaking the value.\n */\nexport function last4(value: string): string {\n if (!value) return \"\";\n if (value.length <= 4) return \"••••\";\n return \"••••\" + value.slice(-4);\n}\n\n// ---------------------------------------------------------------------------\n// CRUD\n// ---------------------------------------------------------------------------\n\nexport interface SecretRef {\n key: string;\n scope: SecretScope;\n scopeId: string;\n}\n\nexport interface WriteSecretArgs extends SecretRef {\n value: string;\n /** Optional human-readable description (used for ad-hoc keys). */\n description?: string;\n /** Optional JSON-stringified array of allowed URL origins. */\n urlAllowlist?: string;\n}\n\n/**\n * Write (insert or update) a secret. The value is encrypted before being\n * stored — the caller's plaintext is never persisted. Returns the new\n * record's id.\n */\nexport async function writeAppSecret(args: WriteSecretArgs): Promise<string> {\n await ensureTable();\n const { key, value, scope, scopeId, description, urlAllowlist } = args;\n if (!key || !value || !scope || !scopeId) {\n throw new Error(\n \"writeAppSecret: key, value, scope, and scopeId are all required\",\n );\n }\n const client = getDbExec();\n const now = Date.now();\n const encrypted = encryptValue(value);\n\n // Upsert by (scope, scope_id, key). Keep the existing row's id on update so\n // references stay stable.\n const { rows } = await client.execute({\n sql: `SELECT id FROM app_secrets WHERE scope = ? AND scope_id = ? AND key = ?`,\n args: [scope, scopeId, key],\n });\n if (rows.length > 0) {\n const id = rows[0].id as string;\n await client.execute({\n sql: `UPDATE app_secrets SET encrypted_value = ?, description = ?, url_allowlist = ?, updated_at = ? WHERE id = ?`,\n args: [encrypted, description ?? null, urlAllowlist ?? null, now, id],\n });\n return id;\n }\n const id = randomUUID();\n await client.execute({\n sql: `INSERT INTO app_secrets (id, scope, scope_id, key, encrypted_value, description, url_allowlist, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n args: [\n id,\n scope,\n scopeId,\n key,\n encrypted,\n description ?? null,\n urlAllowlist ?? null,\n now,\n now,\n ],\n });\n return id;\n}\n\nexport interface ReadSecretResult {\n value: string;\n last4: string;\n updatedAt: number;\n}\n\n/**\n * Read a secret's plaintext value. Returns null when not found. The caller\n * is responsible for never logging the returned value.\n */\nexport async function readAppSecret(\n ref: SecretRef,\n): Promise<ReadSecretResult | null> {\n await ensureTable();\n const { key, scope, scopeId } = ref;\n const client = getDbExec();\n const { rows } = await client.execute({\n sql: `SELECT encrypted_value, updated_at FROM app_secrets WHERE scope = ? AND scope_id = ? AND key = ? LIMIT 1`,\n args: [scope, scopeId, key],\n });\n if (rows.length === 0) return null;\n try {\n const value = decryptValue(rows[0].encrypted_value as string);\n return {\n value,\n last4: last4(value),\n updatedAt: Number(rows[0].updated_at ?? 0),\n };\n } catch {\n // Decryption failure — key rotated, tampered row, etc. Don't throw up the\n // stack in a way that could leak the ciphertext; just report missing.\n return null;\n }\n}\n\n/**\n * Return just the metadata for a secret (no value). Used by the list route so\n * the UI can show the \"Set\" pill and last-4 without the decrypted value going\n * over the wire.\n */\nexport async function getAppSecretMeta(\n ref: SecretRef,\n): Promise<{ last4: string; updatedAt: number } | null> {\n const result = await readAppSecret(ref);\n if (!result) return null;\n return { last4: result.last4, updatedAt: result.updatedAt };\n}\n\nexport interface SecretMeta {\n key: string;\n scope: SecretScope;\n scopeId: string;\n last4: string;\n description: string | null;\n urlAllowlist: string[] | null;\n createdAt: number;\n updatedAt: number;\n}\n\n/**\n * Read a secret's metadata, including ad-hoc fields (description, allowlist),\n * without ever decrypting or returning the plaintext value. Used by the\n * ad-hoc list route and any UI that wants to render a key tile.\n */\nexport async function readAppSecretMeta(\n ref: SecretRef,\n): Promise<SecretMeta | null> {\n await ensureTable();\n const { key, scope, scopeId } = ref;\n const client = getDbExec();\n const { rows } = await client.execute({\n sql: `SELECT encrypted_value, description, url_allowlist, created_at, updated_at FROM app_secrets WHERE scope = ? AND scope_id = ? AND key = ? LIMIT 1`,\n args: [scope, scopeId, key],\n });\n if (rows.length === 0) return null;\n const row = rows[0];\n let last4Value = \"\";\n try {\n const value = decryptValue(row.encrypted_value as string);\n last4Value = last4(value);\n } catch {\n last4Value = \"\";\n }\n return {\n key,\n scope,\n scopeId,\n last4: last4Value,\n description: (row.description as string | null) ?? null,\n urlAllowlist: parseAllowlist(row.url_allowlist as string | null),\n createdAt: Number(row.created_at ?? 0),\n updatedAt: Number(row.updated_at ?? 0),\n };\n}\n\n/**\n * List all secrets for a given scope. Returns metadata only — values are\n * never decrypted or returned. Used by the ad-hoc list route to surface\n * user-created keys.\n */\nexport async function listAppSecretsForScope(\n scope: SecretScope,\n scopeId: string,\n): Promise<SecretMeta[]> {\n await ensureTable();\n const client = getDbExec();\n const { rows } = await client.execute({\n sql: `SELECT key, encrypted_value, description, url_allowlist, created_at, updated_at FROM app_secrets WHERE scope = ? AND scope_id = ? ORDER BY updated_at DESC`,\n args: [scope, scopeId],\n });\n return rows.map((row) => {\n let last4Value = \"\";\n try {\n const value = decryptValue(row.encrypted_value as string);\n last4Value = last4(value);\n } catch {\n last4Value = \"\";\n }\n return {\n key: row.key as string,\n scope,\n scopeId,\n last4: last4Value,\n description: (row.description as string | null) ?? null,\n urlAllowlist: parseAllowlist(row.url_allowlist as string | null),\n createdAt: Number(row.created_at ?? 0),\n updatedAt: Number(row.updated_at ?? 0),\n };\n });\n}\n\nfunction parseAllowlist(raw: string | null): string[] | null {\n if (!raw) return null;\n try {\n const parsed = JSON.parse(raw);\n if (Array.isArray(parsed) && parsed.every((v) => typeof v === \"string\")) {\n return parsed;\n }\n return null;\n } catch {\n return null;\n }\n}\n\nexport async function deleteAppSecret(ref: SecretRef): Promise<boolean> {\n await ensureTable();\n const { key, scope, scopeId } = ref;\n const client = getDbExec();\n const { rowsAffected } = await client.execute({\n sql: `DELETE FROM app_secrets WHERE scope = ? AND scope_id = ? AND key = ?`,\n args: [scope, scopeId, key],\n });\n return rowsAffected > 0;\n}\n"]}
@@ -23,15 +23,15 @@ export declare function resolveAgentEngineApiKeyWriteTarget(event: H3Event, scop
23
23
  error: string;
24
24
  }>;
25
25
  export declare function createAgentEngineApiKeyHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
26
- ok?: undefined;
27
26
  error: any;
28
27
  key?: undefined;
29
28
  scope?: undefined;
29
+ ok?: undefined;
30
30
  } | {
31
- error?: undefined;
32
31
  ok: boolean;
33
32
  key: string;
34
33
  scope: AgentEngineApiKeyScope;
34
+ error?: undefined;
35
35
  }>>;
36
36
  export {};
37
37
  //# sourceMappingURL=agent-engine-api-key-route.d.ts.map
@@ -3,6 +3,10 @@
3
3
  * Supported environment variables:
4
4
  * - `GA_MEASUREMENT_ID` — Google Analytics 4 measurement ID
5
5
  *
6
+ * Netlify configuration-file env vars are build-time only for serverless
7
+ * functions, so the Vite plugin also bakes this public value into SSR bundles
8
+ * as `__AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__`.
9
+ *
6
10
  * Amplitude and Sentry are initialized client-side via their npm packages
7
11
  * (see `packages/core/src/client/analytics.ts`). Only GA requires script
8
12
  * tag injection because the gtag.js loader must be a `<script src>`.
@@ -1 +1 @@
1
- {"version":3,"file":"analytics.d.ts","sourceRoot":"","sources":["../../src/server/analytics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAoBH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,cAAc,GAAG,cAAc,CA4BtE"}
1
+ {"version":3,"file":"analytics.d.ts","sourceRoot":"","sources":["../../src/server/analytics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAwCH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,cAAc,GAAG,cAAc,CA4BtE"}
@@ -3,6 +3,10 @@
3
3
  * Supported environment variables:
4
4
  * - `GA_MEASUREMENT_ID` — Google Analytics 4 measurement ID
5
5
  *
6
+ * Netlify configuration-file env vars are build-time only for serverless
7
+ * functions, so the Vite plugin also bakes this public value into SSR bundles
8
+ * as `__AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__`.
9
+ *
6
10
  * Amplitude and Sentry are initialized client-side via their npm packages
7
11
  * (see `packages/core/src/client/analytics.ts`). Only GA requires script
8
12
  * tag injection because the gtag.js loader must be a `<script src>`.
@@ -16,16 +20,28 @@
16
20
  * return new Response(wrapWithAnalytics(body), { ... });
17
21
  * ```
18
22
  */
23
+ function normalizeMeasurementId(value) {
24
+ const trimmed = value?.trim();
25
+ return trimmed ? trimmed : null;
26
+ }
27
+ function getGaMeasurementId() {
28
+ return (normalizeMeasurementId(process.env.GA_MEASUREMENT_ID) ||
29
+ normalizeMeasurementId(typeof __AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__ === "string"
30
+ ? __AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__
31
+ : undefined));
32
+ }
19
33
  function getGaScript() {
20
- const id = process.env.GA_MEASUREMENT_ID;
34
+ const id = getGaMeasurementId();
21
35
  if (!id)
22
36
  return null;
23
- return (`<script async src="https://www.googletagmanager.com/gtag/js?id=${id}"></script>` +
37
+ const srcId = encodeURIComponent(id);
38
+ const jsId = JSON.stringify(id);
39
+ return (`<script async src="https://www.googletagmanager.com/gtag/js?id=${srcId}"></script>` +
24
40
  `<script>` +
25
41
  `window.dataLayer=window.dataLayer||[];` +
26
42
  `function gtag(){dataLayer.push(arguments);}` +
27
43
  `gtag('js',new Date());` +
28
- `gtag('config','${id}');` +
44
+ `gtag('config',${jsId});` +
29
45
  `if(typeof sessionStorage!=='undefined'&&sessionStorage.getItem('__an_signin')){` +
30
46
  `sessionStorage.removeItem('__an_signin');` +
31
47
  `gtag('event','sign_in');` +
@@ -1 +1 @@
1
- {"version":3,"file":"analytics.js","sourceRoot":"","sources":["../../src/server/analytics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,SAAS,WAAW;IAClB,MAAM,EAAE,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC;IACzC,IAAI,CAAC,EAAE;QAAE,OAAO,IAAI,CAAC;IACrB,OAAO,CACL,kEAAkE,EAAE,aAAa;QACjF,UAAU;QACV,wCAAwC;QACxC,6CAA6C;QAC7C,wBAAwB;QACxB,kBAAkB,EAAE,KAAK;QACzB,iFAAiF;QACjF,2CAA2C;QAC3C,0BAA0B;QAC1B,GAAG;QACH,WAAW,CACZ,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,IAAoB;IACpD,MAAM,OAAO,GAAG,CAAC,WAAW,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACzD,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAC;IAE1B,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;IAClC,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;IAClC,IAAI,QAAQ,GAAG,KAAK,CAAC;IAErB,OAAO,IAAI,CAAC,WAAW,CACrB,IAAI,eAAe,CAAC;QAClB,SAAS,CAAC,KAAK,EAAE,UAAU;YACzB,IAAI,QAAQ,EAAE,CAAC;gBACb,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;gBAC1B,OAAO;YACT,CAAC;YACD,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;YACrD,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;YAC7C,IAAI,YAAY,KAAK,CAAC,CAAC,EAAE,CAAC;gBACxB,MAAM,QAAQ,GACZ,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,YAAY,CAAC,GAAG,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;gBACnE,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;gBAC7C,QAAQ,GAAG,IAAI,CAAC;YAClB,CAAC;iBAAM,CAAC;gBACN,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;YAC5B,CAAC;QACH,CAAC;KACF,CAAC,CACH,CAAC;AACJ,CAAC","sourcesContent":["/**\n * Opt-in analytics injection for SSR streams.\n * Supported environment variables:\n * - `GA_MEASUREMENT_ID` — Google Analytics 4 measurement ID\n *\n * Amplitude and Sentry are initialized client-side via their npm packages\n * (see `packages/core/src/client/analytics.ts`). Only GA requires script\n * tag injection because the gtag.js loader must be a `<script src>`.\n *\n * When set, the corresponding script tags are injected before `</head>`.\n * When not set, the stream passes through untouched (zero overhead).\n *\n * Usage in entry.server.tsx:\n * ```ts\n * import { wrapWithAnalytics } from \"@agent-native/core/server\";\n * return new Response(wrapWithAnalytics(body), { ... });\n * ```\n */\n\nfunction getGaScript(): string | null {\n const id = process.env.GA_MEASUREMENT_ID;\n if (!id) return null;\n return (\n `<script async src=\"https://www.googletagmanager.com/gtag/js?id=${id}\"></script>` +\n `<script>` +\n `window.dataLayer=window.dataLayer||[];` +\n `function gtag(){dataLayer.push(arguments);}` +\n `gtag('js',new Date());` +\n `gtag('config','${id}');` +\n `if(typeof sessionStorage!=='undefined'&&sessionStorage.getItem('__an_signin')){` +\n `sessionStorage.removeItem('__an_signin');` +\n `gtag('event','sign_in');` +\n `}` +\n `</script>`\n );\n}\n\nexport function wrapWithAnalytics(body: ReadableStream): ReadableStream {\n const scripts = [getGaScript()].filter(Boolean).join(\"\");\n if (!scripts) return body;\n\n const decoder = new TextDecoder();\n const encoder = new TextEncoder();\n let injected = false;\n\n return body.pipeThrough(\n new TransformStream({\n transform(chunk, controller) {\n if (injected) {\n controller.enqueue(chunk);\n return;\n }\n const text = decoder.decode(chunk, { stream: true });\n const headCloseIdx = text.indexOf(\"</head>\");\n if (headCloseIdx !== -1) {\n const modified =\n text.slice(0, headCloseIdx) + scripts + text.slice(headCloseIdx);\n controller.enqueue(encoder.encode(modified));\n injected = true;\n } else {\n controller.enqueue(chunk);\n }\n },\n }),\n );\n}\n"]}
1
+ {"version":3,"file":"analytics.js","sourceRoot":"","sources":["../../src/server/analytics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAIH,SAAS,sBAAsB,CAAC,KAAyB;IACvD,MAAM,OAAO,GAAG,KAAK,EAAE,IAAI,EAAE,CAAC;IAC9B,OAAO,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;AAClC,CAAC;AAED,SAAS,kBAAkB;IACzB,OAAO,CACL,sBAAsB,CAAC,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC;QACrD,sBAAsB,CACpB,OAAO,wCAAwC,KAAK,QAAQ;YAC1D,CAAC,CAAC,wCAAwC;YAC1C,CAAC,CAAC,SAAS,CACd,CACF,CAAC;AACJ,CAAC;AAED,SAAS,WAAW;IAClB,MAAM,EAAE,GAAG,kBAAkB,EAAE,CAAC;IAChC,IAAI,CAAC,EAAE;QAAE,OAAO,IAAI,CAAC;IACrB,MAAM,KAAK,GAAG,kBAAkB,CAAC,EAAE,CAAC,CAAC;IACrC,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC;IAChC,OAAO,CACL,kEAAkE,KAAK,aAAa;QACpF,UAAU;QACV,wCAAwC;QACxC,6CAA6C;QAC7C,wBAAwB;QACxB,iBAAiB,IAAI,IAAI;QACzB,iFAAiF;QACjF,2CAA2C;QAC3C,0BAA0B;QAC1B,GAAG;QACH,WAAW,CACZ,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,IAAoB;IACpD,MAAM,OAAO,GAAG,CAAC,WAAW,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACzD,IAAI,CAAC,OAAO;QAAE,OAAO,IAAI,CAAC;IAE1B,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;IAClC,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;IAClC,IAAI,QAAQ,GAAG,KAAK,CAAC;IAErB,OAAO,IAAI,CAAC,WAAW,CACrB,IAAI,eAAe,CAAC;QAClB,SAAS,CAAC,KAAK,EAAE,UAAU;YACzB,IAAI,QAAQ,EAAE,CAAC;gBACb,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;gBAC1B,OAAO;YACT,CAAC;YACD,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;YACrD,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;YAC7C,IAAI,YAAY,KAAK,CAAC,CAAC,EAAE,CAAC;gBACxB,MAAM,QAAQ,GACZ,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,YAAY,CAAC,GAAG,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;gBACnE,UAAU,CAAC,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;gBAC7C,QAAQ,GAAG,IAAI,CAAC;YAClB,CAAC;iBAAM,CAAC;gBACN,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;YAC5B,CAAC;QACH,CAAC;KACF,CAAC,CACH,CAAC;AACJ,CAAC","sourcesContent":["/**\n * Opt-in analytics injection for SSR streams.\n * Supported environment variables:\n * - `GA_MEASUREMENT_ID` — Google Analytics 4 measurement ID\n *\n * Netlify configuration-file env vars are build-time only for serverless\n * functions, so the Vite plugin also bakes this public value into SSR bundles\n * as `__AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__`.\n *\n * Amplitude and Sentry are initialized client-side via their npm packages\n * (see `packages/core/src/client/analytics.ts`). Only GA requires script\n * tag injection because the gtag.js loader must be a `<script src>`.\n *\n * When set, the corresponding script tags are injected before `</head>`.\n * When not set, the stream passes through untouched (zero overhead).\n *\n * Usage in entry.server.tsx:\n * ```ts\n * import { wrapWithAnalytics } from \"@agent-native/core/server\";\n * return new Response(wrapWithAnalytics(body), { ... });\n * ```\n */\n\ndeclare const __AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__: string | undefined;\n\nfunction normalizeMeasurementId(value: string | undefined): string | null {\n const trimmed = value?.trim();\n return trimmed ? trimmed : null;\n}\n\nfunction getGaMeasurementId(): string | null {\n return (\n normalizeMeasurementId(process.env.GA_MEASUREMENT_ID) ||\n normalizeMeasurementId(\n typeof __AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__ === \"string\"\n ? __AGENT_NATIVE_BUILD_GA_MEASUREMENT_ID__\n : undefined,\n )\n );\n}\n\nfunction getGaScript(): string | null {\n const id = getGaMeasurementId();\n if (!id) return null;\n const srcId = encodeURIComponent(id);\n const jsId = JSON.stringify(id);\n return (\n `<script async src=\"https://www.googletagmanager.com/gtag/js?id=${srcId}\"></script>` +\n `<script>` +\n `window.dataLayer=window.dataLayer||[];` +\n `function gtag(){dataLayer.push(arguments);}` +\n `gtag('js',new Date());` +\n `gtag('config',${jsId});` +\n `if(typeof sessionStorage!=='undefined'&&sessionStorage.getItem('__an_signin')){` +\n `sessionStorage.removeItem('__an_signin');` +\n `gtag('event','sign_in');` +\n `}` +\n `</script>`\n );\n}\n\nexport function wrapWithAnalytics(body: ReadableStream): ReadableStream {\n const scripts = [getGaScript()].filter(Boolean).join(\"\");\n if (!scripts) return body;\n\n const decoder = new TextDecoder();\n const encoder = new TextEncoder();\n let injected = false;\n\n return body.pipeThrough(\n new TransformStream({\n transform(chunk, controller) {\n if (injected) {\n controller.enqueue(chunk);\n return;\n }\n const text = decoder.decode(chunk, { stream: true });\n const headCloseIdx = text.indexOf(\"</head>\");\n if (headCloseIdx !== -1) {\n const modified =\n text.slice(0, headCloseIdx) + scripts + text.slice(headCloseIdx);\n controller.enqueue(encoder.encode(modified));\n injected = true;\n } else {\n controller.enqueue(chunk);\n }\n },\n }),\n );\n}\n"]}
@@ -20,7 +20,7 @@ export declare function createTranscribeVoiceHandler(): import("h3").EventHandle
20
20
  error: string;
21
21
  text?: undefined;
22
22
  } | {
23
- text: string;
24
23
  error?: undefined;
24
+ text: string;
25
25
  }>>;
26
26
  //# sourceMappingURL=transcribe-voice.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/settings/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAStC,wBAAgB,kBAAkB,IAAI,YAAY,CAEjD;AA0CD,wBAAsB,UAAU,CAC9B,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC,CAUzC;AAED,MAAM,WAAW,iBAAiB;IAChC,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,wBAAsB,UAAU,CAC9B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,CAAC,IAAI,CAAC,CAgBf;AAED,wBAAsB,aAAa,CACjC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,CAAC,OAAO,CAAC,CAkBlB;AAED,wBAAsB,cAAc,IAAI,OAAO,CAC7C,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CACxC,CAUA"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/settings/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AActC,wBAAgB,kBAAkB,IAAI,YAAY,CAEjD;AAuED,wBAAsB,UAAU,CAC9B,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC,CAUzC;AAED,MAAM,WAAW,iBAAiB;IAChC,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,wBAAsB,UAAU,CAC9B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,CAAC,IAAI,CAAC,CAgBf;AAED,wBAAsB,aAAa,CACjC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,CAAC,OAAO,CAAC,CAkBlB;AAED,wBAAsB,cAAc,IAAI,OAAO,CAC7C,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CACxC,CAUA"}
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from "events";
2
2
  import { getDbExec, isPostgres, intType } from "../db/client.js";
3
+ import { pgIndexExists, pgTableExists, runGuardedDdl, } from "../db/ddl-guard.js";
3
4
  import { widenIntColumnsToBigInt } from "../db/widen-columns.js";
4
5
  let _initPromise;
5
6
  const _emitter = new EventEmitter();
@@ -14,17 +15,42 @@ async function ensureTable() {
14
15
  _initPromise = (async () => {
15
16
  const client = getDbExec();
16
17
  const table = settingsTable();
17
- await client.execute(`
18
+ const createSql = `
18
19
  CREATE TABLE IF NOT EXISTS ${table} (
19
20
  key TEXT PRIMARY KEY,
20
21
  value TEXT NOT NULL,
21
22
  updated_at ${intType()} NOT NULL
22
23
  )
23
- `);
24
- // Older deployments (pre BIGINT-compat) have a 32-bit `updated_at`; on
25
- // Postgres the `Date.now()` written on every setSetting overflows int4.
26
- // Widen in place (no-op once done / on fresh DBs). Pass the unqualified
27
- // name the helper scopes to the `public` schema.
24
+ `;
25
+ if (isPostgres()) {
26
+ // Hot path: the `settings` table and its poll index are virtually
27
+ // always already present in production. Issuing `CREATE TABLE`/
28
+ // `CREATE INDEX` still takes a lock that, in a fresh background-worker
29
+ // process behind a concurrent connection on the shared Neon DB, can
30
+ // block ~indefinitely (ACCESS EXCLUSIVE for CREATE TABLE; a write-
31
+ // blocking SHARE lock for CREATE INDEX). So check `information_schema`/
32
+ // `pg_indexes` first (plain reads, no lock) and run DDL ONLY for what
33
+ // is actually missing. `runGuardedDdl` bounds any DDL that must run
34
+ // with a transaction-scoped `lock_timeout` so a contended lock fails
35
+ // fast instead of hanging. `settingsTable()` is `public.settings` on
36
+ // Postgres; the existence checks use the unqualified table name.
37
+ if (!(await pgTableExists("settings"))) {
38
+ await runGuardedDdl(createSql);
39
+ }
40
+ // Older deployments (pre BIGINT-compat) have a 32-bit `updated_at`; on
41
+ // Postgres the `Date.now()` written on every setSetting overflows int4.
42
+ // widenIntColumnsToBigInt already probes information_schema and only
43
+ // ALTERs columns that are still int4 — a no-op on fresh/widened DBs.
44
+ await widenIntColumnsToBigInt("settings", ["updated_at"]);
45
+ // Index for the poll watermark query: `SELECT MAX(updated_at)`.
46
+ if (!(await pgIndexExists("settings_updated_at_idx"))) {
47
+ await runGuardedDdl(`CREATE INDEX IF NOT EXISTS settings_updated_at_idx ON ${table} (updated_at)`);
48
+ }
49
+ return;
50
+ }
51
+ // SQLite (local dev): no lock problem — keep the original behaviour.
52
+ await client.execute(createSql);
53
+ // No-op on SQLite (INTEGER is already 64-bit).
28
54
  await widenIntColumnsToBigInt("settings", ["updated_at"]);
29
55
  // Index for the poll watermark query: `SELECT MAX(updated_at) FROM settings`.
30
56
  // MAX on an indexed column avoids a full-table scan on every poll cycle.