@bevel-software/platform-core-backend 0.24.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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/modules/database/migrate.d.ts +87 -1
- package/dist/modules/database/migrate.d.ts.map +1 -1
- package/dist/modules/database/migrate.js +166 -48
- 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/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/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +58 -21
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/kb-template/AGENTS.md +40 -0
- package/package.json +3 -3
- package/src/index.ts +7 -0
- package/src/modules/database/__tests__/pii-backfill.pg.test.ts +223 -4
- package/src/modules/database/migrate.ts +238 -52
- 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/tool-manuals/__tests__/tool-manuals.service.test.ts +151 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +37 -0
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +112 -1
- package/src/modules/workspace/workspace.tools.ts +59 -21
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { afterAll, afterEach, describe, expect, it } from 'vitest';
|
|
1
|
+
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import pg from 'pg';
|
|
4
4
|
import { eq, inArray, sql } from 'drizzle-orm';
|
|
5
5
|
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
|
6
6
|
import { closeDb, createDb, type Database } from '../connection.js';
|
|
7
|
-
import {
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { runCoreMigrations, runEnterpriseMigrations, runPiiBackfill, type PiiBackfillSpec, type PiiBlindIndex } from '../migrate.js';
|
|
8
11
|
import { prFileApprovals, users } from '../core-schema.js';
|
|
9
12
|
import { coreMigrationsDir } from '../../../assets.js';
|
|
10
|
-
import { derivePiiKeys, isEncryptedBlob } from '../../../shared/column-crypto.js';
|
|
13
|
+
import { PII_CIPHERTEXT_PREFIX, derivePiiKeys, isEncryptedBlob } from '../../../shared/column-crypto.js';
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* Personal data in a real Postgres: what a database handle stores under its
|
|
@@ -45,6 +48,8 @@ type Query = (text: string) => Promise<Row[]>;
|
|
|
45
48
|
const created: string[] = [];
|
|
46
49
|
const open: Database[] = [];
|
|
47
50
|
const plain: pg.Pool[] = [];
|
|
51
|
+
/** Temporary folders a test made (an overlay's migration history); removed in `afterAll`. */
|
|
52
|
+
const folders: string[] = [];
|
|
48
53
|
|
|
49
54
|
async function withAdmin<T>(fn: (admin: pg.Client) => Promise<T>): Promise<T> {
|
|
50
55
|
const admin = new pg.Client({ connectionString: ADMIN_URL });
|
|
@@ -149,11 +154,14 @@ describe.skipIf(!ADMIN_URL)('personal data, on a real Postgres', () => {
|
|
|
149
154
|
await Promise.all(plain.splice(0).map((pool) => pool.end()));
|
|
150
155
|
});
|
|
151
156
|
|
|
157
|
+
// With the tests' own timeout: every test leaves a scratch database, and
|
|
158
|
+
// dropping them one after the other outlasts the default for a hook.
|
|
152
159
|
afterAll(async () => {
|
|
153
160
|
await withAdmin(async (admin) => {
|
|
154
161
|
for (const name of created) await admin.query(`drop database if exists ${name} with (force)`);
|
|
155
162
|
});
|
|
156
|
-
|
|
163
|
+
for (const folder of folders) rmSync(folder, { recursive: true, force: true });
|
|
164
|
+
}, TIMEOUT);
|
|
157
165
|
|
|
158
166
|
describe('what a handle stores', () => {
|
|
159
167
|
it(
|
|
@@ -558,4 +566,215 @@ describe.skipIf(!ADMIN_URL)('personal data, on a real Postgres', () => {
|
|
|
558
566
|
TIMEOUT,
|
|
559
567
|
);
|
|
560
568
|
});
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* An overlay seals columns of its own schema with the same backfill, by
|
|
572
|
+
* handing `runEnterpriseMigrations` a spec. Its table here has what core's
|
|
573
|
+
* own do not: two indexed addresses in one row, and one of them optional.
|
|
574
|
+
*/
|
|
575
|
+
describe("an overlay's own tables", () => {
|
|
576
|
+
const NOTES: PiiBackfillSpec = {
|
|
577
|
+
name: 'overlay',
|
|
578
|
+
tables: [
|
|
579
|
+
{
|
|
580
|
+
table: 'team_notes',
|
|
581
|
+
key: ['id'],
|
|
582
|
+
encrypted: ['author_email', 'reviewer_email', 'body'],
|
|
583
|
+
bidx: [
|
|
584
|
+
{ source: 'author_email', column: 'author_email_bidx' },
|
|
585
|
+
{ source: 'reviewer_email', column: 'reviewer_email_bidx' },
|
|
586
|
+
],
|
|
587
|
+
},
|
|
588
|
+
],
|
|
589
|
+
marker: { table: 'team_notes', column: 'author_email_bidx' },
|
|
590
|
+
finalize: ['ALTER TABLE "team_notes" ALTER COLUMN "author_email_bidx" SET NOT NULL'],
|
|
591
|
+
keyName: 'OVERLAY_KEY',
|
|
592
|
+
};
|
|
593
|
+
// Typed before anything was sealed, in the very shape of a sealed value.
|
|
594
|
+
const shapedLikeSealed = `${PII_CIPHERTEXT_PREFIX}${'A'.repeat(16)}:${'A'.repeat(22)}==:AAAA`;
|
|
595
|
+
|
|
596
|
+
/** The overlay's migration history: the table, its index columns nullable as a SQL history must add them. */
|
|
597
|
+
function overlayHistory(): string {
|
|
598
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'overlay-migrations-'));
|
|
599
|
+
folders.push(dir);
|
|
600
|
+
mkdirSync(path.join(dir, 'meta'));
|
|
601
|
+
writeFileSync(
|
|
602
|
+
path.join(dir, 'meta', '_journal.json'),
|
|
603
|
+
JSON.stringify({ version: '7', dialect: 'postgresql', entries: [{ idx: 0, version: '7', when: 1, tag: '0000_notes', breakpoints: true }] }),
|
|
604
|
+
);
|
|
605
|
+
writeFileSync(
|
|
606
|
+
path.join(dir, '0000_notes.sql'),
|
|
607
|
+
`CREATE TABLE "team_notes" (
|
|
608
|
+
"id" serial PRIMARY KEY,
|
|
609
|
+
"author_email" text NOT NULL,
|
|
610
|
+
"author_email_bidx" text,
|
|
611
|
+
"reviewer_email" text,
|
|
612
|
+
"reviewer_email_bidx" text,
|
|
613
|
+
"body" text
|
|
614
|
+
);`,
|
|
615
|
+
);
|
|
616
|
+
return dir;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** A database an older version of the overlay wrote to: its history applied, its rows in clear. */
|
|
620
|
+
async function overlayBeforeTheBackfill(key?: string): Promise<{ url: string; db: Database; q: Query; history: string }> {
|
|
621
|
+
const url = await scratchDatabase();
|
|
622
|
+
const db = handle(url, key ? { key } : {});
|
|
623
|
+
const history = overlayHistory();
|
|
624
|
+
await runEnterpriseMigrations(db, history);
|
|
625
|
+
const q = stored(url);
|
|
626
|
+
await q(`insert into team_notes (author_email, reviewer_email, body) values
|
|
627
|
+
('Ada@Example.com', ' Bo@example.com', 'hello'),
|
|
628
|
+
('cy@example.com', null, '${shapedLikeSealed}')`);
|
|
629
|
+
return { url, db, q, history };
|
|
630
|
+
}
|
|
631
|
+
const notes = (q: Query) => q(`select * from team_notes order by id`);
|
|
632
|
+
|
|
633
|
+
it(
|
|
634
|
+
'are sealed right after its history, with every index it declares, and a second start changes nothing',
|
|
635
|
+
async () => {
|
|
636
|
+
const { db, q, history } = await overlayBeforeTheBackfill();
|
|
637
|
+
|
|
638
|
+
await runEnterpriseMigrations(db, history, { piiBackfill: NOTES });
|
|
639
|
+
|
|
640
|
+
const [first, second] = await notes(q);
|
|
641
|
+
expect([first!.author_email, first!.reviewer_email, first!.body, second!.author_email].every(sealed)).toBe(true);
|
|
642
|
+
expect(opened(first!.author_email)).toBe('Ada@Example.com');
|
|
643
|
+
// Both addresses of a row are indexed, each under its own column.
|
|
644
|
+
expect(first!.author_email_bidx).toBe(keys.index('ada@example.com'));
|
|
645
|
+
expect(first!.reviewer_email_bidx).toBe(keys.index('bo@example.com'));
|
|
646
|
+
expect(second!.author_email_bidx).toBe(keys.index('cy@example.com'));
|
|
647
|
+
// An address that is not there has no index: not the index of the
|
|
648
|
+
// empty string, which every such row would share.
|
|
649
|
+
expect(second!.reviewer_email).toBeNull();
|
|
650
|
+
expect(second!.reviewer_email_bidx).toBeNull();
|
|
651
|
+
// A first backfill believes no shape: the text typed in the shape of
|
|
652
|
+
// a sealed value is sealed like any other, and reads back as typed.
|
|
653
|
+
expect(second!.body).not.toBe(shapedLikeSealed);
|
|
654
|
+
expect(opened(second!.body)).toBe(shapedLikeSealed);
|
|
655
|
+
// The marker is closed: no later row can be without its index.
|
|
656
|
+
await expect(q(`insert into team_notes (author_email) values ('x@example.com')`)).rejects.toThrow(/author_email_bidx/);
|
|
657
|
+
|
|
658
|
+
const after = JSON.stringify(await notes(q));
|
|
659
|
+
await runEnterpriseMigrations(db, history, { piiBackfill: NOTES });
|
|
660
|
+
expect(JSON.stringify(await notes(q))).toBe(after);
|
|
661
|
+
},
|
|
662
|
+
TIMEOUT,
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
it(
|
|
666
|
+
'are refused under a key that does not open them, named as the overlay names it',
|
|
667
|
+
async () => {
|
|
668
|
+
const { url, db, q, history } = await overlayBeforeTheBackfill();
|
|
669
|
+
await runEnterpriseMigrations(db, history, { piiBackfill: NOTES });
|
|
670
|
+
const before = JSON.stringify(await notes(q));
|
|
671
|
+
|
|
672
|
+
const rekeyed = handle(url, { key: randomBytes(32).toString('base64') });
|
|
673
|
+
await expect(runEnterpriseMigrations(rekeyed, history, { piiBackfill: NOTES })).rejects.toThrow(/OVERLAY_KEY does not open/);
|
|
674
|
+
expect(JSON.stringify(await notes(q))).toBe(before);
|
|
675
|
+
},
|
|
676
|
+
TIMEOUT,
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
it(
|
|
680
|
+
'are refused under a wrong key even where the first column of a table holds nothing sealed',
|
|
681
|
+
async () => {
|
|
682
|
+
const { url, db, q, history } = await overlayBeforeTheBackfill();
|
|
683
|
+
await runEnterpriseMigrations(db, history, { piiBackfill: NOTES });
|
|
684
|
+
// Nobody has a reviewer: that column has no sealed value to try a key
|
|
685
|
+
// on, and it is the column this spec lists first. The authors' do.
|
|
686
|
+
await q(`update team_notes set reviewer_email = null, reviewer_email_bidx = null`);
|
|
687
|
+
const table = NOTES.tables[0]!;
|
|
688
|
+
const reviewerFirst: PiiBackfillSpec = {
|
|
689
|
+
...NOTES,
|
|
690
|
+
tables: [{ ...table, encrypted: ['reviewer_email', 'author_email', 'body'], bidx: [...(table.bidx as PiiBlindIndex[])].reverse() }],
|
|
691
|
+
};
|
|
692
|
+
const before = JSON.stringify(await notes(q));
|
|
693
|
+
|
|
694
|
+
const rekeyed = handle(url, { key: randomBytes(32).toString('base64') });
|
|
695
|
+
await expect(runPiiBackfill(rekeyed, reviewerFirst)).rejects.toThrow(/team_notes\.author_email is sealed with a key the configured OVERLAY_KEY does not open/);
|
|
696
|
+
expect(JSON.stringify(await notes(q))).toBe(before);
|
|
697
|
+
// The right key is still let through.
|
|
698
|
+
await runPiiBackfill(db, reviewerFirst);
|
|
699
|
+
},
|
|
700
|
+
TIMEOUT,
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
it(
|
|
704
|
+
'keep an index exactly where its source is, and equal where the sources are',
|
|
705
|
+
async () => {
|
|
706
|
+
const { db, q, history } = await overlayBeforeTheBackfill();
|
|
707
|
+
// An empty string is a value: two rows that hold it hold the same one.
|
|
708
|
+
await q(`insert into team_notes (author_email, reviewer_email) values ('di@example.com', ''), ('ed@example.com', '')`);
|
|
709
|
+
await runEnterpriseMigrations(db, history, { piiBackfill: NOTES });
|
|
710
|
+
|
|
711
|
+
const rows = await notes(q);
|
|
712
|
+
// A NULL source has no index; an empty one has the index the handle
|
|
713
|
+
// itself writes for an empty value, so a row is found by it whoever
|
|
714
|
+
// wrote it; and it stays the empty string, which seals to itself.
|
|
715
|
+
expect(rows.map((r) => r.reviewer_email_bidx)).toEqual([keys.index('bo@example.com'), null, keys.index(''), keys.index('')]);
|
|
716
|
+
expect(rows.slice(2).map((r) => r.reviewer_email)).toEqual(['', '']);
|
|
717
|
+
|
|
718
|
+
// A source emptied to NULL behind the application's back leaves its
|
|
719
|
+
// index answering for an address the row no longer holds. The next
|
|
720
|
+
// start takes it away, and touches nothing else.
|
|
721
|
+
await q(`update team_notes set reviewer_email = null where id = 1`);
|
|
722
|
+
const untouched = JSON.stringify((await notes(q)).slice(1));
|
|
723
|
+
await runEnterpriseMigrations(db, history, { piiBackfill: NOTES });
|
|
724
|
+
const after = await notes(q);
|
|
725
|
+
expect(after[0]).toMatchObject({ reviewer_email: null, reviewer_email_bidx: null, author_email_bidx: keys.index('ada@example.com') });
|
|
726
|
+
expect(JSON.stringify(after.slice(1))).toBe(untouched);
|
|
727
|
+
},
|
|
728
|
+
TIMEOUT,
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
it(
|
|
732
|
+
'are left untouched by a spec whose finalize does not close its marker',
|
|
733
|
+
async () => {
|
|
734
|
+
const { db, q } = await overlayBeforeTheBackfill();
|
|
735
|
+
const before = JSON.stringify(await notes(q));
|
|
736
|
+
|
|
737
|
+
// Committed, the next start would take these rows for unsealed again
|
|
738
|
+
// and seal the sealed. So it does not commit.
|
|
739
|
+
await expect(runPiiBackfill(db, { ...NOTES, finalize: [] })).rejects.toThrow(/left the marker column team_notes\.author_email_bidx nullable/);
|
|
740
|
+
expect(JSON.stringify(await notes(q))).toBe(before);
|
|
741
|
+
},
|
|
742
|
+
TIMEOUT,
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
it(
|
|
746
|
+
'are not read at all for a spec that could not be carried through',
|
|
747
|
+
async () => {
|
|
748
|
+
const { url, db, q } = await overlayBeforeTheBackfill();
|
|
749
|
+
const before = JSON.stringify(await notes(q));
|
|
750
|
+
const table = NOTES.tables[0]!;
|
|
751
|
+
// Everything the backfill asks the database, it asks inside the one
|
|
752
|
+
// transaction it opens. So "not read" is checked where it would
|
|
753
|
+
// happen: these refusals must come before any transaction is opened,
|
|
754
|
+
// which unchanged rows alone would not show.
|
|
755
|
+
const keyless = handle(url, { key: null });
|
|
756
|
+
const opened = [vi.spyOn(db, 'transaction'), vi.spyOn(db, 'execute'), vi.spyOn(keyless, 'transaction'), vi.spyOn(keyless, 'execute')];
|
|
757
|
+
|
|
758
|
+
// A marker that is no index of the spec's tables.
|
|
759
|
+
await expect(runPiiBackfill(db, { ...NOTES, marker: { table: 'team_notes', column: 'body' } })).rejects.toThrow(/is not a blind-index column/);
|
|
760
|
+
// An index of a column that is not sealed: its plaintext is never read.
|
|
761
|
+
await expect(
|
|
762
|
+
runPiiBackfill(db, { ...NOTES, tables: [{ ...table, encrypted: ['reviewer_email', 'body'] }] }),
|
|
763
|
+
).rejects.toThrow(/indexes "author_email", which is not one of the table's encrypted columns/);
|
|
764
|
+
// A handle that holds no key.
|
|
765
|
+
await expect(runPiiBackfill(keyless, NOTES)).rejects.toThrow(/holds no personal-data key/);
|
|
766
|
+
for (const spy of opened) {
|
|
767
|
+
expect(spy).not.toHaveBeenCalled();
|
|
768
|
+
spy.mockRestore();
|
|
769
|
+
}
|
|
770
|
+
expect(JSON.stringify(await notes(q))).toBe(before);
|
|
771
|
+
|
|
772
|
+
// And a database the history was never applied to: nothing says the
|
|
773
|
+
// rows are sealed, so nothing is assumed.
|
|
774
|
+
const empty = handle(await scratchDatabase());
|
|
775
|
+
await expect(runPiiBackfill(empty, NOTES)).rejects.toThrow(/marker column team_notes\.author_email_bidx does not exist/);
|
|
776
|
+
},
|
|
777
|
+
TIMEOUT,
|
|
778
|
+
);
|
|
779
|
+
});
|
|
561
780
|
});
|