@agent-native/core 0.77.7 → 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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +17 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/production-agent.ts +18 -0
- package/corpus/core/src/db/ddl-guard.ts +190 -0
- package/corpus/core/src/secrets/storage.ts +36 -2
- package/corpus/core/src/settings/store.ts +40 -6
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +18 -0
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/db/ddl-guard.d.ts +88 -0
- package/dist/db/ddl-guard.d.ts.map +1 -0
- package/dist/db/ddl-guard.js +180 -0
- package/dist/db/ddl-guard.js.map +1 -0
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/storage.d.ts.map +1 -1
- package/dist/secrets/storage.js +26 -2
- package/dist/secrets/storage.js.map +1 -1
- package/dist/settings/store.d.ts.map +1 -1
- package/dist/settings/store.js +32 -6
- package/dist/settings/store.js.map +1 -1
- package/package.json +1 -1
package/dist/collab/routes.d.ts
CHANGED
|
@@ -41,8 +41,8 @@ export declare const postCollabUpdate: import("h3").EventHandlerWithFetch<import
|
|
|
41
41
|
* Body: { text: string, fieldName?: string, requestSource?: string }
|
|
42
42
|
*/
|
|
43
43
|
export declare const postCollabText: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
44
|
-
text?: undefined;
|
|
45
44
|
ok?: undefined;
|
|
45
|
+
text?: undefined;
|
|
46
46
|
error: string;
|
|
47
47
|
} | {
|
|
48
48
|
error?: undefined;
|
|
@@ -0,0 +1,88 @@
|
|
|
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 { type DbExec } from "./client.js";
|
|
32
|
+
/**
|
|
33
|
+
* True when running against Postgres AND the given table already exists in the
|
|
34
|
+
* `public` schema. Returns `false` on SQLite (callers gate their own behaviour
|
|
35
|
+
* there), for invalid identifiers, or when `information_schema` is unreadable —
|
|
36
|
+
* the conservative answer "not known to exist" makes the caller fall through to
|
|
37
|
+
* its idempotent `CREATE TABLE IF NOT EXISTS`, preserving today's behaviour.
|
|
38
|
+
*
|
|
39
|
+
* This is a plain read (no lock), so it never blocks on an `ACCESS EXCLUSIVE`
|
|
40
|
+
* lock the way `CREATE`/`ALTER` would.
|
|
41
|
+
*/
|
|
42
|
+
export declare function pgTableExists(table: string, injectedClient?: DbExec): Promise<boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* True when running against Postgres AND the given column already exists on the
|
|
45
|
+
* given table in the `public` schema. Returns `false` on SQLite, for invalid
|
|
46
|
+
* identifiers, or when `information_schema` is unreadable.
|
|
47
|
+
*
|
|
48
|
+
* Plain read — no lock taken.
|
|
49
|
+
*/
|
|
50
|
+
export declare function pgColumnExists(table: string, column: string, injectedClient?: DbExec): Promise<boolean>;
|
|
51
|
+
/**
|
|
52
|
+
* True when running against Postgres AND an index with the given name already
|
|
53
|
+
* exists in the `public` schema. Returns `false` on SQLite, for invalid
|
|
54
|
+
* identifiers, or when `pg_indexes` is unreadable.
|
|
55
|
+
*
|
|
56
|
+
* `CREATE INDEX` (without CONCURRENTLY) takes a `SHARE` lock that blocks
|
|
57
|
+
* writes, so on a fresh background-worker process behind a concurrent
|
|
58
|
+
* connection this can hang just like a `CREATE`/`ALTER` would; checking first
|
|
59
|
+
* skips the lock on the already-migrated hot path.
|
|
60
|
+
*/
|
|
61
|
+
export declare function pgIndexExists(indexName: string, injectedClient?: DbExec): Promise<boolean>;
|
|
62
|
+
/** True when an error looks like a Postgres `lock_timeout` (SQLSTATE 55P03). */
|
|
63
|
+
export declare function isLockTimeoutError(err: unknown): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Run a DDL statement that MUST execute (the schema is actually missing), with
|
|
66
|
+
* a short `lock_timeout` so a contended `ACCESS EXCLUSIVE` lock fails fast
|
|
67
|
+
* instead of hanging the whole process.
|
|
68
|
+
*
|
|
69
|
+
* Postgres path: wrap the DDL in an explicit transaction and set
|
|
70
|
+
* `SET LOCAL lock_timeout` so the timeout is scoped to THIS transaction only
|
|
71
|
+
* and reset automatically on COMMIT/ROLLBACK — it never leaks onto the pooled
|
|
72
|
+
* (session-reused) connection the way a bare `SET lock_timeout` would. If the
|
|
73
|
+
* DbExec has no `transaction` (shouldn't happen for Postgres, but defensively),
|
|
74
|
+
* fall back to a session `SET` + `RESET` in a finally. A lock-timeout error is
|
|
75
|
+
* swallowed: the table/column is virtually always already correct by the time a
|
|
76
|
+
* contended boot retries, and the caller's memoization should still resolve so
|
|
77
|
+
* the path isn't retried in a tight loop. Any non-lock-timeout error rethrows.
|
|
78
|
+
*
|
|
79
|
+
* SQLite path: no lock problem — just run the DDL directly.
|
|
80
|
+
*
|
|
81
|
+
* @returns `true` if the DDL ran to completion, `false` if it was skipped due to
|
|
82
|
+
* a lock-timeout (so the caller can decide whether to log).
|
|
83
|
+
*/
|
|
84
|
+
export declare function runGuardedDdl(ddl: string, options?: {
|
|
85
|
+
lockTimeout?: string;
|
|
86
|
+
injectedClient?: DbExec;
|
|
87
|
+
}): Promise<boolean>;
|
|
88
|
+
//# sourceMappingURL=ddl-guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ddl-guard.d.ts","sourceRoot":"","sources":["../../src/db/ddl-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,OAAO,EAAyB,KAAK,MAAM,EAAE,MAAM,aAAa,CAAC;AAIjE;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CACjC,KAAK,EAAE,MAAM,EACb,cAAc,CAAC,EAAE,MAAM,GACtB,OAAO,CAAC,OAAO,CAAC,CAelB;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,cAAc,CAAC,EAAE,MAAM,GACtB,OAAO,CAAC,OAAO,CAAC,CAiBlB;AAED;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CACjC,SAAS,EAAE,MAAM,EACjB,cAAc,CAAC,EAAE,MAAM,GACtB,OAAO,CAAC,OAAO,CAAC,CAalB;AAED,gFAAgF;AAChF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAKxD;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,MAAM,EACX,OAAO,GAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CAAO,GAC9D,OAAO,CAAC,OAAO,CAAC,CAmClB"}
|
|
@@ -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"]}
|
|
@@ -49,8 +49,8 @@ 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
55
|
error?: undefined;
|
|
56
56
|
ok: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../src/secrets/storage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;
|
|
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"}
|
package/dist/secrets/storage.js
CHANGED
|
@@ -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
|
|
29
|
+
const createSql = isPostgres()
|
|
29
30
|
? APP_SECRETS_CREATE_SQL.replace(/\bINTEGER\b/g, "BIGINT")
|
|
30
31
|
: APP_SECRETS_CREATE_SQL;
|
|
31
|
-
|
|
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"]}
|
|
@@ -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;
|
|
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"}
|
package/dist/settings/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/settings/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AAEjE,IAAI,YAAuC,CAAC;AAE5C,MAAM,QAAQ,GAAG,IAAI,YAAY,EAAE,CAAC;AAEpC,MAAM,UAAU,kBAAkB;IAChC,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,SAAS,aAAa;IACpB,OAAO,UAAU,EAAE,CAAC,CAAC,CAAC,iBAAiB,CAAC,CAAC,CAAC,UAAU,CAAC;AACvD,CAAC;AAED,KAAK,UAAU,WAAW;IACxB,IAAI,CAAC,YAAY,EAAE,CAAC;QAClB,YAAY,GAAG,CAAC,KAAK,IAAI,EAAE;YACzB,MAAM,MAAM,GAAG,SAAS,EAAE,CAAC;YAC3B,MAAM,KAAK,GAAG,aAAa,EAAE,CAAC;YAC9B,MAAM,
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../../src/settings/store.ts"],"names":[],"mappings":"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","sourcesContent":["import { EventEmitter } from \"events\";\n\nimport { getDbExec, isPostgres, intType } from \"../db/client.js\";\nimport {\n pgIndexExists,\n pgTableExists,\n runGuardedDdl,\n} from \"../db/ddl-guard.js\";\nimport { widenIntColumnsToBigInt } from \"../db/widen-columns.js\";\n\nlet _initPromise: Promise<void> | undefined;\n\nconst _emitter = new EventEmitter();\n\nexport function getSettingsEmitter(): EventEmitter {\n return _emitter;\n}\n\nfunction settingsTable(): string {\n return isPostgres() ? \"public.settings\" : \"settings\";\n}\n\nasync function ensureTable(): Promise<void> {\n if (!_initPromise) {\n _initPromise = (async () => {\n const client = getDbExec();\n const table = settingsTable();\n const createSql = `\n CREATE TABLE IF NOT EXISTS ${table} (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at ${intType()} NOT NULL\n )\n `;\n\n if (isPostgres()) {\n // Hot path: the `settings` table and its poll index are virtually\n // always already present in production. Issuing `CREATE TABLE`/\n // `CREATE INDEX` still takes a lock that, in a fresh background-worker\n // process behind a concurrent connection on the shared Neon DB, can\n // block ~indefinitely (ACCESS EXCLUSIVE for CREATE TABLE; a write-\n // blocking SHARE lock for CREATE INDEX). So check `information_schema`/\n // `pg_indexes` first (plain reads, no lock) and run DDL ONLY for what\n // is actually missing. `runGuardedDdl` bounds any DDL that must run\n // with a transaction-scoped `lock_timeout` so a contended lock fails\n // fast instead of hanging. `settingsTable()` is `public.settings` on\n // Postgres; the existence checks use the unqualified table name.\n if (!(await pgTableExists(\"settings\"))) {\n await runGuardedDdl(createSql);\n }\n // Older deployments (pre BIGINT-compat) have a 32-bit `updated_at`; on\n // Postgres the `Date.now()` written on every setSetting overflows int4.\n // widenIntColumnsToBigInt already probes information_schema and only\n // ALTERs columns that are still int4 — a no-op on fresh/widened DBs.\n await widenIntColumnsToBigInt(\"settings\", [\"updated_at\"]);\n // Index for the poll watermark query: `SELECT MAX(updated_at)`.\n if (!(await pgIndexExists(\"settings_updated_at_idx\"))) {\n await runGuardedDdl(\n `CREATE INDEX IF NOT EXISTS settings_updated_at_idx ON ${table} (updated_at)`,\n );\n }\n return;\n }\n\n // SQLite (local dev): no lock problem — keep the original behaviour.\n await client.execute(createSql);\n // No-op on SQLite (INTEGER is already 64-bit).\n await widenIntColumnsToBigInt(\"settings\", [\"updated_at\"]);\n // Index for the poll watermark query: `SELECT MAX(updated_at) FROM settings`.\n // MAX on an indexed column avoids a full-table scan on every poll cycle.\n // IF NOT EXISTS makes it idempotent on existing databases.\n try {\n await client.execute(\n `CREATE INDEX IF NOT EXISTS settings_updated_at_idx ON ${table} (updated_at)`,\n );\n } catch {\n // Index already exists or the dialect rejected a duplicate.\n }\n })().catch((err) => {\n // Retry init on the next call after a failed startup.\n _initPromise = undefined;\n throw err;\n });\n }\n return _initPromise;\n}\n\nexport async function getSetting(\n key: string,\n): Promise<Record<string, unknown> | null> {\n await ensureTable();\n const client = getDbExec();\n const table = settingsTable();\n const { rows } = await client.execute({\n sql: `SELECT value FROM ${table} WHERE key = ?`,\n args: [key],\n });\n if (rows.length === 0) return null;\n return JSON.parse(rows[0].value as string);\n}\n\nexport interface StoreWriteOptions {\n /** Tag identifying who initiated this write (e.g. a tab ID). */\n requestSource?: string;\n}\n\nexport async function putSetting(\n key: string,\n value: Record<string, unknown>,\n options?: StoreWriteOptions,\n): Promise<void> {\n await ensureTable();\n const client = getDbExec();\n const table = settingsTable();\n await client.execute({\n sql: isPostgres()\n ? `INSERT INTO ${table} (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=EXCLUDED.updated_at`\n : `INSERT OR REPLACE INTO ${table} (key, value, updated_at) VALUES (?, ?, ?)`,\n args: [key, JSON.stringify(value), Date.now()],\n });\n _emitter.emit(\"settings\", {\n source: \"settings\",\n type: \"change\",\n key,\n ...(options?.requestSource && { requestSource: options.requestSource }),\n });\n}\n\nexport async function deleteSetting(\n key: string,\n options?: StoreWriteOptions,\n): Promise<boolean> {\n await ensureTable();\n const client = getDbExec();\n const table = settingsTable();\n const result = await client.execute({\n sql: `DELETE FROM ${table} WHERE key = ?`,\n args: [key],\n });\n if (result.rowsAffected > 0) {\n _emitter.emit(\"settings\", {\n source: \"settings\",\n type: \"delete\",\n key,\n ...(options?.requestSource && { requestSource: options.requestSource }),\n });\n return true;\n }\n return false;\n}\n\nexport async function getAllSettings(): Promise<\n Record<string, Record<string, unknown>>\n> {\n await ensureTable();\n const client = getDbExec();\n const table = settingsTable();\n const { rows } = await client.execute(`SELECT key, value FROM ${table}`);\n const result: Record<string, Record<string, unknown>> = {};\n for (const row of rows) {\n result[row.key as string] = JSON.parse(row.value as string);\n }\n return result;\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.77.
|
|
3
|
+
"version": "0.77.8",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|