@bevel-software/platform-core-backend 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +17 -3
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +3 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +25 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +12 -0
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +10 -0
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/core-config.d.ts +16 -0
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +17 -0
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -2
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +4 -1
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -1
- package/dist/modules/access/directory-sync-bot.js +7 -3
- package/dist/modules/access/directory-sync-bot.js.map +1 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts +8 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.d.ts.map +1 -1
- package/dist/modules/agent-instructions/agent-instructions.routes.js +8 -2
- package/dist/modules/agent-instructions/agent-instructions.routes.js.map +1 -1
- package/dist/modules/agent-instructions/compose.d.ts +38 -6
- package/dist/modules/agent-instructions/compose.d.ts.map +1 -1
- package/dist/modules/agent-instructions/compose.js +39 -6
- package/dist/modules/agent-instructions/compose.js.map +1 -1
- package/dist/modules/agent-instructions/index.d.ts +2 -1
- package/dist/modules/agent-instructions/index.d.ts.map +1 -1
- package/dist/modules/agent-instructions/index.js +2 -1
- package/dist/modules/agent-instructions/index.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +115 -0
- package/dist/modules/agent-instructions/shared-file-rules.d.ts.map +1 -0
- package/dist/modules/agent-instructions/shared-file-rules.js +272 -0
- package/dist/modules/agent-instructions/shared-file-rules.js.map +1 -0
- package/dist/modules/audit/agent-audit.service.d.ts.map +1 -1
- package/dist/modules/audit/agent-audit.service.js +4 -2
- package/dist/modules/audit/agent-audit.service.js.map +1 -1
- package/dist/modules/auth/account-erasure.service.d.ts.map +1 -1
- package/dist/modules/auth/account-erasure.service.js +57 -16
- package/dist/modules/auth/account-erasure.service.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +25 -15
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.d.ts +20 -2
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +66 -35
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/database/connection.d.ts +16 -0
- package/dist/modules/database/connection.d.ts.map +1 -1
- package/dist/modules/database/connection.js +117 -0
- package/dist/modules/database/connection.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +296 -96
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +81 -31
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/database/migrate.d.ts +95 -1
- package/dist/modules/database/migrate.d.ts.map +1 -1
- package/dist/modules/database/migrate.js +390 -2
- package/dist/modules/database/migrate.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +17 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +29 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +8 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +38 -8
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/plugins/join-request-records.store.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-records.store.js +7 -4
- package/dist/modules/plugins/join-request-records.store.js.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
- package/dist/modules/tool-auth/external-api-key.service.js +8 -2
- package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +30 -0
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +42 -31
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/tool-registry/description-length.d.ts +80 -0
- package/dist/modules/tool-registry/description-length.d.ts.map +1 -0
- package/dist/modules/tool-registry/description-length.js +108 -0
- package/dist/modules/tool-registry/description-length.js.map +1 -0
- package/dist/modules/workflow/git/git.service.d.ts +25 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +40 -1
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +5 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/recovery-bot.d.ts.map +1 -1
- package/dist/modules/workflow/recovery-bot.js +7 -3
- package/dist/modules/workflow/recovery-bot.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +4 -1
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/agent-upload.routes.d.ts +77 -0
- package/dist/modules/workspace/agent-upload.routes.d.ts.map +1 -0
- package/dist/modules/workspace/agent-upload.routes.js +210 -0
- package/dist/modules/workspace/agent-upload.routes.js.map +1 -0
- package/dist/modules/workspace/agent-upload.store.d.ts +284 -0
- package/dist/modules/workspace/agent-upload.store.d.ts.map +1 -0
- package/dist/modules/workspace/agent-upload.store.js +553 -0
- package/dist/modules/workspace/agent-upload.store.js.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/seed-tree.js +3 -3
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.d.ts +40 -0
- package/dist/modules/workspace/startup/steps/template-source.d.ts.map +1 -1
- package/dist/modules/workspace/startup/steps/template-source.js +46 -4
- package/dist/modules/workspace/startup/steps/template-source.js.map +1 -1
- package/dist/modules/workspace/upload-limits.d.ts +13 -0
- package/dist/modules/workspace/upload-limits.d.ts.map +1 -0
- package/dist/modules/workspace/upload-limits.js +13 -0
- package/dist/modules/workspace/upload-limits.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +1 -1
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +13 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +61 -33
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +11 -9
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +570 -134
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/modules/workspace/write-denial.d.ts +0 -6
- package/dist/modules/workspace/write-denial.d.ts.map +1 -1
- package/dist/modules/workspace/write-denial.js +0 -6
- package/dist/modules/workspace/write-denial.js.map +1 -1
- package/dist/modules/workspace/zip-entry-rules.d.ts +114 -0
- package/dist/modules/workspace/zip-entry-rules.d.ts.map +1 -0
- package/dist/modules/workspace/zip-entry-rules.js +154 -0
- package/dist/modules/workspace/zip-entry-rules.js.map +1 -0
- package/dist/shared/column-crypto.d.ts +194 -0
- package/dist/shared/column-crypto.d.ts.map +1 -0
- package/dist/shared/column-crypto.js +144 -0
- package/dist/shared/column-crypto.js.map +1 -0
- package/dist/shared/token-crypto.d.ts.map +1 -1
- package/dist/shared/token-crypto.js +25 -1
- package/dist/shared/token-crypto.js.map +1 -1
- package/dist/tenancy/static-tenant-source.d.ts.map +1 -1
- package/dist/tenancy/static-tenant-source.js +1 -0
- package/dist/tenancy/static-tenant-source.js.map +1 -1
- package/dist/tenancy/tenant-secrets.d.ts +5 -1
- package/dist/tenancy/tenant-secrets.d.ts.map +1 -1
- package/dist/tenancy/tenant-secrets.js +4 -0
- package/dist/tenancy/tenant-secrets.js.map +1 -1
- package/kb-template/AGENTS.md +42 -0
- package/migrations/0016_pii_encryption.sql +20 -0
- package/migrations/meta/0016_snapshot.json +2327 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/core/__tests__/lifecycle.test.ts +71 -4
- package/src/core/create-core-server.ts +21 -3
- package/src/core/create-core-services.ts +27 -1
- package/src/core/lifecycle.ts +19 -0
- package/src/core-config.ts +18 -0
- package/src/index.ts +25 -0
- package/src/modules/access/__tests__/users-db-double.ts +21 -12
- package/src/modules/access/access.routes.ts +4 -1
- package/src/modules/access/directory-sync-bot.ts +7 -3
- package/src/modules/agent-instructions/__tests__/agent-instructions.route.test.ts +8 -4
- package/src/modules/agent-instructions/__tests__/compose.test.ts +19 -10
- package/src/modules/agent-instructions/__tests__/shared-file-rules.test.ts +238 -0
- package/src/modules/agent-instructions/agent-instructions.routes.ts +12 -2
- package/src/modules/agent-instructions/compose.ts +50 -7
- package/src/modules/agent-instructions/index.ts +12 -0
- package/src/modules/agent-instructions/shared-file-rules.ts +314 -0
- package/src/modules/audit/agent-audit.service.ts +4 -2
- package/src/modules/auth/__tests__/account-deactivation.test.ts +2 -1
- package/src/modules/auth/__tests__/account-erasure.approval-gate.test.ts +6 -2
- package/src/modules/auth/__tests__/account.routes.test.ts +6 -3
- package/src/modules/auth/__tests__/auth.service.test.ts +3 -1
- package/src/modules/auth/account-erasure.service.ts +69 -18
- package/src/modules/auth/auth.service.ts +33 -23
- package/src/modules/code-mode/__tests__/chain-runtime.e2e.test.ts +335 -0
- package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +46 -2
- package/src/modules/code-mode/code-mode.tool.ts +80 -34
- package/src/modules/database/__tests__/connection.test.ts +12 -0
- package/src/modules/database/__tests__/pii-backfill.pg.test.ts +780 -0
- package/src/modules/database/connection.ts +117 -0
- package/src/modules/database/core-schema.ts +81 -31
- package/src/modules/database/migrate.ts +540 -2
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +93 -0
- package/src/modules/kb-fs/locking-filesystem.ts +37 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +4 -3
- package/src/modules/mcp/__tests__/mcp.service.test.ts +47 -7
- package/src/modules/mcp/mcp.service.ts +46 -7
- package/src/modules/plugins/join-request-records.store.ts +7 -4
- package/src/modules/tool-auth/external-api-key.service.ts +8 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +151 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +37 -0
- package/src/modules/tool-manuals/tool-manuals.tools.ts +44 -31
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +378 -0
- package/src/modules/tool-registry/description-length.ts +111 -0
- package/src/modules/workflow/git/__tests__/git.service.prFetchFailure.test.ts +170 -0
- package/src/modules/workflow/git/git.service.ts +47 -1
- package/src/modules/workflow/pending-commits.service.ts +5 -1
- package/src/modules/workflow/recovery-bot.ts +7 -3
- package/src/modules/workflow/review-workflow/__tests__/carry-approvals-forward.test.ts +4 -0
- package/src/modules/workflow/review-workflow/__tests__/erase-approver.test.ts +25 -3
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
- package/src/modules/workflow/workflow.service.ts +4 -1
- package/src/modules/workspace/__tests__/agent-uploads.test.ts +1604 -0
- package/src/modules/workspace/__tests__/escape-sequences.routes.test.ts +24 -14
- package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +156 -0
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +77 -1
- package/src/modules/workspace/__tests__/workspace.service.test.ts +57 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +39 -36
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +196 -46
- package/src/modules/workspace/agent-upload.routes.ts +214 -0
- package/src/modules/workspace/agent-upload.store.ts +668 -0
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +9 -9
- package/src/modules/workspace/startup/steps/seed-tree.ts +3 -6
- package/src/modules/workspace/startup/steps/template-source.ts +53 -5
- package/src/modules/workspace/upload-limits.ts +12 -0
- package/src/modules/workspace/workspace.routes.ts +1 -2
- package/src/modules/workspace/workspace.service.ts +63 -37
- package/src/modules/workspace/workspace.tools.ts +647 -148
- package/src/modules/workspace/write-denial.ts +0 -8
- package/src/modules/workspace/zip-entry-rules.ts +173 -0
- package/src/shared/__tests__/column-crypto.test.ts +217 -0
- package/src/shared/column-crypto.ts +218 -0
- package/src/shared/token-crypto.ts +28 -1
- package/src/tenancy/__tests__/static-tenant-source.test.ts +4 -0
- package/src/tenancy/static-tenant-source.ts +1 -0
- package/src/tenancy/tenant-secrets.ts +5 -1
|
@@ -2,6 +2,7 @@ import { sql } from 'drizzle-orm';
|
|
|
2
2
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
3
3
|
import pg from 'pg';
|
|
4
4
|
import * as schema from './schema.js';
|
|
5
|
+
import { PII_CIPHERTEXT_PREFIX, PiiParam, derivePiiKeys, type PiiKeys } from '../../shared/column-crypto.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* One database, one schema per knowledge base.
|
|
@@ -74,6 +75,83 @@ export interface DbOptions {
|
|
|
74
75
|
max?: number;
|
|
75
76
|
/** How long an idle connection is kept before the pool closes it. Default: `pg`'s. */
|
|
76
77
|
idleTimeoutMillis?: number;
|
|
78
|
+
/**
|
|
79
|
+
* The key this handle seals personal data with: the `SECRETS_ENC_KEY` of
|
|
80
|
+
* the knowledge base whose rows it holds (its own derived one, for a
|
|
81
|
+
* tenant). Without it the handle reads sealed values as their blobs and
|
|
82
|
+
* refuses to write one — right for a handle that only touches tables with
|
|
83
|
+
* no personal data, and a loud failure for one that was meant to have it.
|
|
84
|
+
*/
|
|
85
|
+
piiKey?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type QueryFn = (this: pg.Client, config?: unknown, values?: unknown, callback?: unknown) => unknown;
|
|
89
|
+
type ResultCallback = (err: unknown, result: unknown) => void;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The `pg` client of a handle that holds `keys`: the one place personal data
|
|
93
|
+
* changes form. Going in, every value a personal-data column marked (see
|
|
94
|
+
* `column-crypto.ts`) is replaced by its ciphertext or its blind index.
|
|
95
|
+
* Coming out, every sealed value of a result is opened — by its prefix, not
|
|
96
|
+
* by its column, so raw SQL through the handle reads plaintext too; a
|
|
97
|
+
* statement that needs what is STORED asks for it as bytes
|
|
98
|
+
* (`convert_to(col, 'UTF8')`).
|
|
99
|
+
*
|
|
100
|
+
* At the client, because that is what the handle owns: the pool builds every
|
|
101
|
+
* connection from this class, a transaction's included, so there is no path
|
|
102
|
+
* from this handle to the database that goes round it. Results that are
|
|
103
|
+
* streamed to a `Submittable` (a cursor) are not opened; nothing here uses one.
|
|
104
|
+
*/
|
|
105
|
+
function keyedClient(keys: PiiKeys | null): typeof pg.Client {
|
|
106
|
+
const stored = (value: unknown): unknown => {
|
|
107
|
+
if (!(value instanceof PiiParam)) return value;
|
|
108
|
+
return keys ? value.stored(keys) : value.toPostgres();
|
|
109
|
+
};
|
|
110
|
+
const openCell = (value: unknown): unknown =>
|
|
111
|
+
typeof value === 'string' && value.startsWith(PII_CIPHERTEXT_PREFIX) ? keys!.read(value) : value;
|
|
112
|
+
const openOne = (result: unknown): void => {
|
|
113
|
+
const rows = (result as { rows?: unknown } | null)?.rows;
|
|
114
|
+
if (!Array.isArray(rows)) return;
|
|
115
|
+
for (const row of rows) {
|
|
116
|
+
if (Array.isArray(row)) {
|
|
117
|
+
for (let i = 0; i < row.length; i++) row[i] = openCell(row[i]);
|
|
118
|
+
} else if (row && typeof row === 'object') {
|
|
119
|
+
const record = row as Record<string, unknown>;
|
|
120
|
+
for (const column of Object.keys(record)) record[column] = openCell(record[column]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const open = <T,>(result: T): T => {
|
|
125
|
+
if (!keys) return result;
|
|
126
|
+
// Several statements in one query string come back as several results.
|
|
127
|
+
if (Array.isArray(result)) result.forEach(openOne);
|
|
128
|
+
else openOne(result);
|
|
129
|
+
return result;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const base = pg.Client.prototype.query as unknown as QueryFn;
|
|
133
|
+
class KeyedClient extends pg.Client {}
|
|
134
|
+
const query: QueryFn = function (config, values, callback) {
|
|
135
|
+
if (typeof values === 'function') {
|
|
136
|
+
callback = values;
|
|
137
|
+
values = undefined;
|
|
138
|
+
}
|
|
139
|
+
if (Array.isArray(values)) {
|
|
140
|
+
values = values.map(stored);
|
|
141
|
+
} else if (config && typeof config === 'object' && Array.isArray((config as { values?: unknown }).values)) {
|
|
142
|
+
const withValues = config as { values: unknown[]; submit?: unknown };
|
|
143
|
+
if (typeof withValues.submit === 'function') withValues.values = withValues.values.map(stored);
|
|
144
|
+
else config = { ...withValues, values: withValues.values.map(stored) };
|
|
145
|
+
}
|
|
146
|
+
if (typeof callback === 'function') {
|
|
147
|
+
const done = callback as ResultCallback;
|
|
148
|
+
return base.call(this, config, values, (err: unknown, result: unknown) => done(err, err ? result : open(result)));
|
|
149
|
+
}
|
|
150
|
+
const pending = base.call(this, config, values);
|
|
151
|
+
return pending instanceof Promise ? pending.then(open) : pending;
|
|
152
|
+
};
|
|
153
|
+
Object.defineProperty(KeyedClient.prototype, 'query', { value: query, writable: true, configurable: true });
|
|
154
|
+
return KeyedClient;
|
|
77
155
|
}
|
|
78
156
|
|
|
79
157
|
/**
|
|
@@ -96,7 +174,11 @@ const CONNECT_TIMEOUT_MS = 30_000;
|
|
|
96
174
|
*/
|
|
97
175
|
export function createDb(databaseUrl: string, opts: DbOptions = {}) {
|
|
98
176
|
const dbSchema = assertSchemaName(opts.schema ?? DEFAULT_DB_SCHEMA);
|
|
177
|
+
// Only an ABSENT key means "this handle holds none". A blank or malformed
|
|
178
|
+
// one was meant to be a key, and is refused here rather than read as none.
|
|
179
|
+
const keys = opts.piiKey === undefined ? null : derivePiiKeys(opts.piiKey);
|
|
99
180
|
const pool = new pg.Pool({
|
|
181
|
+
Client: keyedClient(keys),
|
|
100
182
|
connectionString: databaseUrl,
|
|
101
183
|
connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
|
|
102
184
|
max: opts.max,
|
|
@@ -109,6 +191,7 @@ export function createDb(databaseUrl: string, opts: DbOptions = {}) {
|
|
|
109
191
|
});
|
|
110
192
|
const db = drizzle(pool, { schema });
|
|
111
193
|
schemas.set(db, dbSchema);
|
|
194
|
+
if (keys) piiKeys.set(db, { keys, of: opts.piiKey! });
|
|
112
195
|
return db;
|
|
113
196
|
}
|
|
114
197
|
|
|
@@ -119,7 +202,22 @@ export function dbSchemaOf(db: Pick<Database, '$client'>): string {
|
|
|
119
202
|
return schemas.get(db as Database) ?? DEFAULT_DB_SCHEMA;
|
|
120
203
|
}
|
|
121
204
|
|
|
205
|
+
/**
|
|
206
|
+
* The keys a handle seals personal data with — for the few callers that
|
|
207
|
+
* handle the stored form themselves (the backfill at start; an overlay's own
|
|
208
|
+
* SQL). Everything else never sees a key: the column types and the handle's
|
|
209
|
+
* connection do the work. Throws for a handle built without one.
|
|
210
|
+
*/
|
|
211
|
+
export function piiKeysOf(db: Pick<Database, '$client'>): PiiKeys {
|
|
212
|
+
const held = piiKeys.get(db as Database);
|
|
213
|
+
if (!held) {
|
|
214
|
+
throw new Error('This database handle holds no personal-data key: build it with createDb/getDb and its piiKey.');
|
|
215
|
+
}
|
|
216
|
+
return held.keys;
|
|
217
|
+
}
|
|
218
|
+
|
|
122
219
|
const schemas = new WeakMap<Database, string>();
|
|
220
|
+
const piiKeys = new WeakMap<Database, { keys: PiiKeys; of: string }>();
|
|
123
221
|
const cache = new Map<string, Database>();
|
|
124
222
|
|
|
125
223
|
const cacheKey = (databaseUrl: string, dbSchema: string) => `${dbSchema}\u0000${databaseUrl}`;
|
|
@@ -136,10 +234,29 @@ export function getDb(databaseUrl: string, opts: DbOptions = {}): Database {
|
|
|
136
234
|
if (!db) {
|
|
137
235
|
db = createDb(databaseUrl, opts);
|
|
138
236
|
cache.set(key, db);
|
|
237
|
+
} else if (opts.piiKey !== undefined && !holdsKey(db, opts.piiKey)) {
|
|
238
|
+
// One schema is one knowledge base and one key. Handing back the cached
|
|
239
|
+
// pool would seal this caller's rows under the other caller's key.
|
|
240
|
+
throw new Error(`The database handle for schema "${dbSchema}" is already open with a different personal-data key.`);
|
|
139
241
|
}
|
|
140
242
|
return db;
|
|
141
243
|
}
|
|
142
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Whether `db` was opened with the key `piiKey` spells. The KEY, not its
|
|
247
|
+
* spelling: `SECRETS_ENC_KEY` may be written as hex or as base64, and one
|
|
248
|
+
* process can be handed the same 32 bytes both ways — refusing the second as
|
|
249
|
+
* "a different key" would refuse a caller that holds the right one. Compared
|
|
250
|
+
* through what the two derive, so no key material is held here to compare.
|
|
251
|
+
*/
|
|
252
|
+
function holdsKey(db: Database, piiKey: string): boolean {
|
|
253
|
+
const held = piiKeys.get(db);
|
|
254
|
+
if (!held) return false;
|
|
255
|
+
if (held.of === piiKey) return true;
|
|
256
|
+
const probe = 'same-key?';
|
|
257
|
+
return held.keys.index(probe) === derivePiiKeys(piiKey).index(probe);
|
|
258
|
+
}
|
|
259
|
+
|
|
143
260
|
/**
|
|
144
261
|
* End a handle's pool and forget it, so a later {@link getDb} for the same
|
|
145
262
|
* schema opens a fresh one rather than handing out a pool that was ended.
|
|
@@ -9,15 +9,32 @@
|
|
|
9
9
|
* `enterprise-schema.ts`, which imports the FK targets (`users`,
|
|
10
10
|
* `externalApiKeys`) from here. `schema.ts` re-exports both, so existing
|
|
11
11
|
* imports keep working unchanged.
|
|
12
|
+
*
|
|
13
|
+
* PII columns (emails, display names, change-request/comment text) are
|
|
14
|
+
* `encryptedText` — AES-256-GCM ciphertext in the database, transparently
|
|
15
|
+
* decrypted on read (see `shared/column-crypto.ts`). Because the ciphertext is
|
|
16
|
+
* randomized, equality lookups and unique constraints on those columns go
|
|
17
|
+
* through their deterministic `*_bidx` companions (HMAC-SHA256 blind indexes,
|
|
18
|
+
* `blindIndexText`) — never `eq()` an encrypted column directly. A `*_bidx`
|
|
19
|
+
* column is written and compared WITH THE ADDRESS ITSELF; the database handle
|
|
20
|
+
* the statement runs on turns it into the index, under that handle's key.
|
|
12
21
|
*/
|
|
13
22
|
import { sql } from 'drizzle-orm';
|
|
14
23
|
import { boolean, check, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
|
24
|
+
import { blindIndexText, encryptedText } from '../../shared/column-crypto.js';
|
|
15
25
|
|
|
16
26
|
export const users = pgTable('users', {
|
|
17
27
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
18
|
-
email:
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
email: encryptedText('email').notNull(),
|
|
29
|
+
/**
|
|
30
|
+
* Blind index of the lowercased email — carries the uniqueness constraint
|
|
31
|
+
* and every lookup-by-email (login, upsert conflict target), which the
|
|
32
|
+
* randomized `email` ciphertext cannot. Write the email into it on every
|
|
33
|
+
* insert, and compare it with the email.
|
|
34
|
+
*/
|
|
35
|
+
emailBidx: blindIndexText('email_bidx').notNull(),
|
|
36
|
+
name: encryptedText('name').notNull(),
|
|
37
|
+
avatarUrl: encryptedText('avatar_url'),
|
|
21
38
|
/**
|
|
22
39
|
* scrypt hash for password login (see auth/password-hash.ts). NULL for
|
|
23
40
|
* accounts that only ever signed in via SSO — password login refuses them
|
|
@@ -52,7 +69,9 @@ export const users = pgTable('users', {
|
|
|
52
69
|
deactivatedAt: timestamp('deactivated_at'),
|
|
53
70
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
54
71
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
55
|
-
})
|
|
72
|
+
}, (t) => ({
|
|
73
|
+
emailBidxUnq: uniqueIndex('users_email_bidx_unq').on(t.emailBidx),
|
|
74
|
+
}));
|
|
56
75
|
|
|
57
76
|
/**
|
|
58
77
|
* Per-file owner approvals on a PR. A PR is mergeable-in-Bevel when every
|
|
@@ -69,15 +88,18 @@ export const prFileApprovals = pgTable('pr_file_approvals', {
|
|
|
69
88
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
70
89
|
prNumber: integer('pr_number').notNull(),
|
|
71
90
|
path: text('path').notNull(),
|
|
72
|
-
approverEmail:
|
|
73
|
-
|
|
91
|
+
approverEmail: encryptedText('approver_email').notNull(), // lowercased at insert
|
|
92
|
+
/** Blind index of `approver_email` — the uniqueness key and eq() lookup. */
|
|
93
|
+
approverEmailBidx: blindIndexText('approver_email_bidx').notNull(),
|
|
94
|
+
approverName: encryptedText('approver_name').notNull(),
|
|
74
95
|
headSha: text('head_sha').notNull(),
|
|
75
96
|
approvedAt: timestamp('approved_at').defaultNow().notNull(),
|
|
76
97
|
}, (t) => ({
|
|
77
98
|
// Idempotency: approving the same path on the same SHA twice must be a no-op,
|
|
78
|
-
// not a duplicate row. Unique on the (PR, path, approver, headSha) tuple
|
|
79
|
-
|
|
80
|
-
|
|
99
|
+
// not a duplicate row. Unique on the (PR, path, approver, headSha) tuple —
|
|
100
|
+
// via the blind index, because the encrypted email is randomized.
|
|
101
|
+
unq: uniqueIndex('pr_file_approvals_bidx_unq')
|
|
102
|
+
.on(t.prNumber, t.path, t.approverEmailBidx, t.headSha),
|
|
81
103
|
byPr: index('pr_file_approvals_by_pr').on(t.prNumber),
|
|
82
104
|
}));
|
|
83
105
|
|
|
@@ -91,12 +113,15 @@ export const prFileApprovals = pgTable('pr_file_approvals', {
|
|
|
91
113
|
export const prMergeLog = pgTable('pr_merge_log', {
|
|
92
114
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
93
115
|
prNumber: integer('pr_number').notNull(),
|
|
94
|
-
triggeredByEmail:
|
|
95
|
-
|
|
116
|
+
triggeredByEmail: encryptedText('triggered_by_email').notNull(),
|
|
117
|
+
/** Blind index of `triggered_by_email` — GDPR-erasure lookup. */
|
|
118
|
+
triggeredByEmailBidx: blindIndexText('triggered_by_email_bidx').notNull(),
|
|
119
|
+
triggeredByName: encryptedText('triggered_by_name').notNull(),
|
|
96
120
|
headShaAtMerge: text('head_sha_at_merge').notNull(),
|
|
97
121
|
mergeMethod: text('merge_method').notNull(),
|
|
98
122
|
succeeded: boolean('succeeded').notNull(),
|
|
99
|
-
|
|
123
|
+
// Encrypted: merge failure output can quote author identities and CR text.
|
|
124
|
+
error: encryptedText('error'),
|
|
100
125
|
startedAt: timestamp('started_at').defaultNow().notNull(),
|
|
101
126
|
completedAt: timestamp('completed_at'),
|
|
102
127
|
}, (t) => ({
|
|
@@ -120,12 +145,15 @@ export const prMergeLog = pgTable('pr_merge_log', {
|
|
|
120
145
|
export const prComments = pgTable('pr_comments', {
|
|
121
146
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
122
147
|
prNumber: integer('pr_number').notNull(),
|
|
123
|
-
authorEmail:
|
|
124
|
-
|
|
148
|
+
authorEmail: encryptedText('author_email').notNull(),
|
|
149
|
+
/** Blind index of `author_email` — GDPR-erasure lookup. */
|
|
150
|
+
authorEmailBidx: blindIndexText('author_email_bidx').notNull(),
|
|
151
|
+
authorName: encryptedText('author_name').notNull(),
|
|
125
152
|
path: text('path'),
|
|
126
153
|
line: integer('line'),
|
|
127
154
|
headSha: text('head_sha').notNull(),
|
|
128
|
-
|
|
155
|
+
// Encrypted: review conversation exists only in this DB (never on GitHub).
|
|
156
|
+
body: encryptedText('body').notNull(),
|
|
129
157
|
parentId: uuid('parent_id'),
|
|
130
158
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
131
159
|
updatedAt: timestamp('updated_at'),
|
|
@@ -154,20 +182,36 @@ export const changeRequests = pgTable('change_requests', {
|
|
|
154
182
|
number: integer('number').generatedAlwaysAsIdentity(),
|
|
155
183
|
sourceBranch: text('source_branch').notNull(),
|
|
156
184
|
targetBranch: text('target_branch').notNull(),
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
185
|
+
// Encrypted: CR title/body exist only in this DB (the remote sees branches).
|
|
186
|
+
// The DB-level default '' bypasses toDriver, which is fine: the empty string
|
|
187
|
+
// is deliberately stored unencrypted (see encryptPii), so a defaulted row
|
|
188
|
+
// and an app-written empty body hold the identical representation.
|
|
189
|
+
title: encryptedText('title').notNull(),
|
|
190
|
+
body: encryptedText('body').notNull().default(''),
|
|
191
|
+
authorEmail: encryptedText('author_email').notNull(), // lowercased at insert
|
|
192
|
+
/** Blind index of `author_email` — GDPR-erasure lookup. */
|
|
193
|
+
authorEmailBidx: blindIndexText('author_email_bidx').notNull(),
|
|
194
|
+
authorName: encryptedText('author_name').notNull(),
|
|
161
195
|
state: text('state').notNull().default('open'), // 'open' | 'merged' | 'closed'
|
|
162
196
|
mergedSha: text('merged_sha'),
|
|
163
197
|
// The last apply attempt that did not land (null when none, or once a gate
|
|
164
198
|
// input it depended on changed). Persisted rather than only pushed to the
|
|
165
199
|
// clicker so every viewer of the still-open request — its author first —
|
|
166
200
|
// sees the refusal.
|
|
167
|
-
|
|
201
|
+
// Encrypted: the reason can quote identities the way the merge log's error
|
|
202
|
+
// does, and the name is a person's.
|
|
203
|
+
applyFailureReason: encryptedText('apply_failure_reason'),
|
|
168
204
|
applyFailureConflicts: boolean('apply_failure_conflicts'),
|
|
169
205
|
applyFailedAt: timestamp('apply_failed_at'),
|
|
170
|
-
applyFailedByName:
|
|
206
|
+
applyFailedByName: encryptedText('apply_failed_by_name'),
|
|
207
|
+
/**
|
|
208
|
+
* Blind index of the email of the person named above — what account erasure
|
|
209
|
+
* finds their name by. Written and cleared with the name, so a name never
|
|
210
|
+
* stands without it: a refusal recorded before the encryption release kept
|
|
211
|
+
* no address, and its name is taken off at the first start on this release
|
|
212
|
+
* (`clearUnindexedRefusalNames` in `migrate.ts`).
|
|
213
|
+
*/
|
|
214
|
+
applyFailedByEmailBidx: blindIndexText('apply_failed_by_email_bidx'),
|
|
171
215
|
/** What refused the last apply: 'gate' (approvals), 'conflicts' (git), 'error' (anything else). */
|
|
172
216
|
applyFailureKind: text('apply_failure_kind'),
|
|
173
217
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
@@ -270,7 +314,7 @@ export const fileLocks = pgTable('file_locks', {
|
|
|
270
314
|
branch: text('branch').notNull(),
|
|
271
315
|
path: text('path').notNull(),
|
|
272
316
|
holderUserId: uuid('holder_user_id').notNull().references(() => users.id),
|
|
273
|
-
holderName:
|
|
317
|
+
holderName: encryptedText('holder_name').notNull(),
|
|
274
318
|
// How the lock was acquired. 'edit' (the default) is a normal write hold —
|
|
275
319
|
// the holder is editing and the release publishes their bytes.
|
|
276
320
|
// 'coordination' is a pure-mutex hold (see IWorkflowService.acquireLock)
|
|
@@ -331,8 +375,10 @@ export const pendingCommits = pgTable('pending_commits', {
|
|
|
331
375
|
// Author attribution captured at enqueue. The denormalised name/email pair
|
|
332
376
|
// lands on the eventual `git commit --author=` so the commit history shows
|
|
333
377
|
// the human who triggered the save, not the worker.
|
|
334
|
-
authorEmail:
|
|
335
|
-
|
|
378
|
+
authorEmail: encryptedText('author_email').notNull(), // lowercased at insert
|
|
379
|
+
/** Blind index of `author_email` — GDPR-erasure lookup. */
|
|
380
|
+
authorEmailBidx: blindIndexText('author_email_bidx').notNull(),
|
|
381
|
+
authorName: encryptedText('author_name').notNull(),
|
|
336
382
|
queuedAt: timestamp('queued_at').defaultNow().notNull(),
|
|
337
383
|
// `running` is set while the worker is mid-commit so a second worker
|
|
338
384
|
// (e.g. after a restart with another instance still draining) doesn't
|
|
@@ -345,7 +391,8 @@ export const pendingCommits = pgTable('pending_commits', {
|
|
|
345
391
|
// `needs_attention` + feedback notice instead of looping forever.
|
|
346
392
|
recoveryAgentRuns: integer('recovery_agent_runs').notNull().default(0),
|
|
347
393
|
lastAttemptedAt: timestamp('last_attempted_at'),
|
|
348
|
-
|
|
394
|
+
// Encrypted: git/agent failure output can quote author identities and paths.
|
|
395
|
+
lastError: encryptedText('last_error'),
|
|
349
396
|
}, (t) => ({
|
|
350
397
|
// Primary read pattern: drain in queued order, scoped to a workspace.
|
|
351
398
|
byWorkspaceQueued: index('pending_commits_by_workspace_queued').on(t.workspaceId, t.queuedAt),
|
|
@@ -718,13 +765,15 @@ export const githubFacadeCodes = pgTable('github_facade_codes', {
|
|
|
718
765
|
*/
|
|
719
766
|
export const pluginJoinRequests = pgTable('plugin_join_requests', {
|
|
720
767
|
id: uuid('id').defaultRandom().primaryKey(),
|
|
721
|
-
requesterEmail:
|
|
768
|
+
requesterEmail: encryptedText('requester_email').notNull(), // lowercased at insert
|
|
769
|
+
/** Blind index of `requester_email` — the uniqueness key, the by-requester read and the erasure delete. */
|
|
770
|
+
requesterEmailBidx: blindIndexText('requester_email_bidx').notNull(),
|
|
722
771
|
/** Denormalised for the commit/change-request authorship, like `file_locks.holder_name`. */
|
|
723
|
-
requesterName:
|
|
772
|
+
requesterName: encryptedText('requester_name').notNull(),
|
|
724
773
|
pluginKey: text('plugin_key').notNull(),
|
|
725
774
|
status: text('status').notNull().default('pending'),
|
|
726
|
-
/** What the git work said when it refused — shown to the requester verbatim. */
|
|
727
|
-
failureReason:
|
|
775
|
+
/** What the git work said when it refused — shown to the requester verbatim. Encrypted: git quotes identities. */
|
|
776
|
+
failureReason: encryptedText('failure_reason'),
|
|
728
777
|
changeRequestNumber: integer('change_request_number'),
|
|
729
778
|
/**
|
|
730
779
|
* When a process took this row's git work, and the whole of the mutual
|
|
@@ -770,8 +819,9 @@ export const pluginJoinRequests = pgTable('plugin_join_requests', {
|
|
|
770
819
|
}, (t) => ({
|
|
771
820
|
// One request per person per plugin — the DB's rule, not a caller's. Also
|
|
772
821
|
// the index the plugin listing's by-requester read is served from.
|
|
773
|
-
|
|
774
|
-
|
|
822
|
+
// Via the blind index, because the encrypted email is randomized.
|
|
823
|
+
requesterPluginUnq: uniqueIndex('plugin_join_requests_requester_bidx_plugin_unq')
|
|
824
|
+
.on(t.requesterEmailBidx, t.pluginKey),
|
|
775
825
|
// The boot sweep: every row still `pending`, without a full scan.
|
|
776
826
|
byStatus: index('plugin_join_requests_by_status').on(t.status),
|
|
777
827
|
statusCheck: check(
|