@pikku/kysely 0.13.22 → 0.13.24

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/CHANGELOG.md CHANGED
@@ -1,3 +1,30 @@
1
+ ## 0.13.24
2
+
3
+ ### Patch Changes
4
+
5
+ - f10162a: `send()` now reports the delivery id it wrote, not only the broker's job id.
6
+
7
+ `KyselyWebhookService.send()` returned `{ jobId }` straight from `queueService.add()`, and callers handed that to `getDelivery()`. That only worked because every queue in the test suite echoed the requested `jobId` back. A broker is free to assign its own identity — the JetStream queue returns a stream sequence — so on those the read-back looked up an id that was never written and 404'd.
8
+
9
+ `SendWebhookResult` gains an optional `deliveryId`, which store-backed implementations always populate.
10
+
11
+ - Updated dependencies [f10162a]
12
+ - @pikku/core@0.12.104
13
+
14
+ ## 0.13.23
15
+
16
+ ### Patch Changes
17
+
18
+ - 80eb5c0: Encrypt classified columns from the generated manifest.
19
+
20
+ `ClassificationPlugin` reads the per-column classification manifest and decrypts `wrapped` and `sealed` columns transparently on the way out. Writes are not transparent — Kysely's `transformQuery` is synchronous and WebCrypto is not — so plaintext heading for a classified column **throws** instead, and values are produced by `ClassificationCrypto.encryptColumn()`. A forgotten call site is a loud error rather than a silent plaintext row. The stored envelope is self-describing (`pikku1.<keyId>.<version>.<wrappedDek>.<ciphertext>`), so a row records which key opens it without a schema change to every table.
21
+
22
+ `keyId` now flows from the hand-authored `db/annotations.ts` through `pikku db migrate` into `classification.gen.ts`. It is emitted only for `wrapped` and `sealed` columns: naming a key on a plain column would claim a protection it does not have, and a hashed column has no key at all — the hash is the lookup key.
23
+
24
+ - Updated dependencies [80eb5c0]
25
+ - Updated dependencies [2252016]
26
+ - @pikku/core@0.12.98
27
+
1
28
  ## 0.13.22
2
29
 
3
30
  ### Patch Changes
@@ -0,0 +1,72 @@
1
+ import { DEFAULT_KEY_ID } from '@pikku/core/classification';
2
+ import type { WrappedValue } from '@pikku/core/classification';
3
+ /**
4
+ * The key a column is protected by when its classification names none. A
5
+ * deployment that never scopes its keys stores this id in every row and still
6
+ * gets the scoping seam for free.
7
+ *
8
+ * Re-exported from core rather than declared again: the same constant decides
9
+ * which record `initialize` mints and which one a stored envelope names, and
10
+ * two copies of it would only ever be found to disagree by a row that could
11
+ * not be opened.
12
+ */
13
+ export { DEFAULT_KEY_ID };
14
+ /** The parts of a stored envelope, as they came off the column. */
15
+ export interface ColumnEnvelope {
16
+ keyId: string;
17
+ keyVersion: number;
18
+ wrappedDek: string;
19
+ ciphertext: string;
20
+ }
21
+ /**
22
+ * A KEK for one `keyId`, plus the version it was derived at.
23
+ *
24
+ * The version is the resolver's to report rather than the caller's to ask for:
25
+ * on a write it stamps the row, and on a read the row already carries the
26
+ * version the value was written under.
27
+ */
28
+ export interface ResolvedKEK {
29
+ kek: CryptoKey;
30
+ keyVersion: number;
31
+ }
32
+ /**
33
+ * Maps a `keyId` — and, when reading, the version the row was written under —
34
+ * to the KEK that opens it.
35
+ *
36
+ * This is the seam the whole design turns on. An unlock gate, a per-tenant
37
+ * lookup or a KMS call all replace this one function without reshaping anything
38
+ * that calls it.
39
+ */
40
+ export type KEKResolver = (keyId: string, keyVersion?: number) => Promise<ResolvedKEK>;
41
+ /**
42
+ * Read a stored envelope, or null when the value is not one.
43
+ *
44
+ * Every part is base64url except the version, so `.` cannot occur inside a part
45
+ * and a fixed split is unambiguous — a `keyId` may contain dots.
46
+ */
47
+ export declare const parseColumnEnvelope: (value: unknown) => ColumnEnvelope | null;
48
+ export declare const isColumnEnvelope: (value: unknown) => boolean;
49
+ export interface ClassificationCryptoOptions {
50
+ resolveKEK: KEKResolver;
51
+ }
52
+ /**
53
+ * Envelope encryption for a single classified column.
54
+ *
55
+ * Unlike `KyselySecretService`, which owns its table and can afford a column
56
+ * per envelope part, a classified column is one column in someone else's table.
57
+ * So the envelope is self-describing: the `keyId` and `keyVersion` travel in
58
+ * the value, which is what lets a row record the key that protects it without
59
+ * a schema change to every table that has a secret in it.
60
+ */
61
+ export declare class ClassificationCrypto {
62
+ private readonly resolveKEK;
63
+ constructor(options: ClassificationCryptoOptions);
64
+ encryptColumn(keyId: string, value: unknown): Promise<WrappedValue>;
65
+ decryptColumn<T = unknown>(value: unknown): Promise<T>;
66
+ /**
67
+ * Move a value to another key by re-wrapping its DEK. The ciphertext is
68
+ * untouched, so the cost is a constant per row rather than proportional to
69
+ * the value — the property that makes rescoping and rotation affordable.
70
+ */
71
+ rewrapColumn(value: unknown, newKeyId: string): Promise<WrappedValue>;
72
+ }
@@ -0,0 +1,104 @@
1
+ import { envelopeDecrypt, envelopeEncrypt, envelopeRewrap, } from '@pikku/core/crypto-utils';
2
+ import { DEFAULT_KEY_ID } from '@pikku/core/classification';
3
+ /**
4
+ * The key a column is protected by when its classification names none. A
5
+ * deployment that never scopes its keys stores this id in every row and still
6
+ * gets the scoping seam for free.
7
+ *
8
+ * Re-exported from core rather than declared again: the same constant decides
9
+ * which record `initialize` mints and which one a stored envelope names, and
10
+ * two copies of it would only ever be found to disagree by a row that could
11
+ * not be opened.
12
+ */
13
+ export { DEFAULT_KEY_ID };
14
+ const ENVELOPE_PREFIX = 'pikku1';
15
+ const encodePart = (value) => Buffer.from(value, 'utf8').toString('base64url');
16
+ const decodePart = (value) => Buffer.from(value, 'base64url').toString('utf8');
17
+ const serializeEnvelope = (envelope) => [
18
+ ENVELOPE_PREFIX,
19
+ encodePart(envelope.keyId),
20
+ String(envelope.keyVersion),
21
+ envelope.wrappedDek,
22
+ envelope.ciphertext,
23
+ ].join('.');
24
+ /**
25
+ * Read a stored envelope, or null when the value is not one.
26
+ *
27
+ * Every part is base64url except the version, so `.` cannot occur inside a part
28
+ * and a fixed split is unambiguous — a `keyId` may contain dots.
29
+ */
30
+ export const parseColumnEnvelope = (value) => {
31
+ if (typeof value !== 'string')
32
+ return null;
33
+ const parts = value.split('.');
34
+ if (parts.length !== 5 || parts[0] !== ENVELOPE_PREFIX)
35
+ return null;
36
+ const [, encodedKeyId, rawVersion, wrappedDek, ciphertext] = parts;
37
+ const keyVersion = Number(rawVersion);
38
+ if (!Number.isInteger(keyVersion) || !wrappedDek || !ciphertext)
39
+ return null;
40
+ try {
41
+ return {
42
+ keyId: decodePart(encodedKeyId),
43
+ keyVersion,
44
+ wrappedDek,
45
+ ciphertext,
46
+ };
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ };
52
+ export const isColumnEnvelope = (value) => parseColumnEnvelope(value) !== null;
53
+ /**
54
+ * Envelope encryption for a single classified column.
55
+ *
56
+ * Unlike `KyselySecretService`, which owns its table and can afford a column
57
+ * per envelope part, a classified column is one column in someone else's table.
58
+ * So the envelope is self-describing: the `keyId` and `keyVersion` travel in
59
+ * the value, which is what lets a row record the key that protects it without
60
+ * a schema change to every table that has a secret in it.
61
+ */
62
+ export class ClassificationCrypto {
63
+ resolveKEK;
64
+ constructor(options) {
65
+ this.resolveKEK = options.resolveKEK;
66
+ }
67
+ async encryptColumn(keyId, value) {
68
+ const { kek, keyVersion } = await this.resolveKEK(keyId);
69
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(kek, value);
70
+ return serializeEnvelope({
71
+ keyId,
72
+ keyVersion,
73
+ wrappedDek: wrappedDEK,
74
+ ciphertext,
75
+ });
76
+ }
77
+ async decryptColumn(value) {
78
+ const envelope = parseColumnEnvelope(value);
79
+ if (!envelope) {
80
+ throw new Error('Value is not a pikku column envelope');
81
+ }
82
+ const { kek } = await this.resolveKEK(envelope.keyId, envelope.keyVersion);
83
+ return envelopeDecrypt(kek, envelope.ciphertext, envelope.wrappedDek);
84
+ }
85
+ /**
86
+ * Move a value to another key by re-wrapping its DEK. The ciphertext is
87
+ * untouched, so the cost is a constant per row rather than proportional to
88
+ * the value — the property that makes rescoping and rotation affordable.
89
+ */
90
+ async rewrapColumn(value, newKeyId) {
91
+ const envelope = parseColumnEnvelope(value);
92
+ if (!envelope) {
93
+ throw new Error('Value is not a pikku column envelope');
94
+ }
95
+ const { kek: oldKEK } = await this.resolveKEK(envelope.keyId, envelope.keyVersion);
96
+ const { kek: newKEK, keyVersion } = await this.resolveKEK(newKeyId);
97
+ return serializeEnvelope({
98
+ keyId: newKeyId,
99
+ keyVersion,
100
+ wrappedDek: await envelopeRewrap(oldKEK, newKEK, envelope.wrappedDek),
101
+ ciphertext: envelope.ciphertext,
102
+ });
103
+ }
104
+ }
@@ -0,0 +1,28 @@
1
+ import type { KyselyPlugin } from 'kysely';
2
+ import type { ClassificationManifest } from '@pikku/core/classification';
3
+ import { type ClassificationCrypto } from './classification-crypto.js';
4
+ export interface CreateClassificationPluginOptions {
5
+ manifest: ClassificationManifest;
6
+ crypto: ClassificationCrypto;
7
+ }
8
+ /**
9
+ * Make a column's at-rest form a property of the column rather than of the
10
+ * developer's memory.
11
+ *
12
+ * Reads are transparent: a `wrapped` column comes back decrypted, so
13
+ * application code never sees an envelope. `hashed` and `plain` columns are
14
+ * untouched, which is what keeps the rest of the schema queryable.
15
+ *
16
+ * Writes are *guarded* rather than transformed, and the reason is Kysely's
17
+ * shape, not a preference: `transformQuery` is synchronous and WebCrypto is
18
+ * not, so a plugin cannot encrypt on the way in. Encrypting at the call site
19
+ * and letting a forgotten call slip through silently is the failure this
20
+ * design exists to prevent, so the guard refuses any plaintext heading for a
21
+ * `wrapped` or `sealed` column. Values are produced with
22
+ * `ClassificationCrypto.encryptColumn`.
23
+ *
24
+ * A `sealed` column is never decrypted on the way out — the application holds
25
+ * only the public half by definition, so returning it as stored is correct
26
+ * rather than a gap.
27
+ */
28
+ export declare function createClassificationPlugin(options: CreateClassificationPluginOptions): KyselyPlugin;
@@ -0,0 +1,199 @@
1
+ import { DEFAULT_KEY_ID, isColumnEnvelope, } from './classification-crypto.js';
2
+ function snakeToCamel(name) {
3
+ return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
4
+ }
5
+ /**
6
+ * Flatten the manifest into a table-qualified lookup plus a bare column-name
7
+ * fallback, both indexed under snake_case and camelCase so the plugin works on
8
+ * either side of `CamelCasePlugin` in the array.
9
+ *
10
+ * A bare name two tables disagree about is dropped rather than letting the last
11
+ * one win: on a joined query it could carry either, and guessing wrong means
12
+ * either handing back ciphertext or refusing a legitimate write. The qualified
13
+ * map still resolves it whenever the query names its tables.
14
+ */
15
+ function buildManifest(manifest) {
16
+ const qualified = new Map();
17
+ const flat = new Map();
18
+ const ambiguous = new Set();
19
+ const assignFlat = (key, column) => {
20
+ const existing = flat.get(key);
21
+ if (existing && existing.keyId !== column.keyId) {
22
+ ambiguous.add(key);
23
+ return;
24
+ }
25
+ flat.set(key, column);
26
+ };
27
+ for (const [table, columns] of Object.entries(manifest.tables)) {
28
+ for (const [name, classification] of Object.entries(columns)) {
29
+ const form = classification.form;
30
+ if (form !== 'wrapped' && form !== 'sealed')
31
+ continue;
32
+ const column = {
33
+ form,
34
+ keyId: classification.keyId ?? DEFAULT_KEY_ID,
35
+ };
36
+ const camel = snakeToCamel(name);
37
+ qualified.set(`${table}.${name}`, column);
38
+ qualified.set(`${table}.${camel}`, column);
39
+ assignFlat(name, column);
40
+ assignFlat(camel, column);
41
+ }
42
+ }
43
+ for (const key of ambiguous)
44
+ flat.delete(key);
45
+ return { qualified, flat };
46
+ }
47
+ function collectQueryTables(node, out) {
48
+ if (!node || typeof node !== 'object')
49
+ return;
50
+ const op = node;
51
+ if (op.kind === 'TableNode') {
52
+ const name = op.table?.identifier?.name;
53
+ if (typeof name === 'string' && name.length > 0)
54
+ out.add(name);
55
+ }
56
+ for (const value of Object.values(node)) {
57
+ if (Array.isArray(value)) {
58
+ for (const item of value)
59
+ collectQueryTables(item, out);
60
+ }
61
+ else {
62
+ collectQueryTables(value, out);
63
+ }
64
+ }
65
+ }
66
+ function lookupColumn(manifest, tables, column) {
67
+ let matched;
68
+ for (const table of tables) {
69
+ const found = manifest.qualified.get(`${table}.${column}`);
70
+ if (!found)
71
+ continue;
72
+ if (matched && matched.keyId !== found.keyId) {
73
+ return manifest.flat.get(column);
74
+ }
75
+ matched = found;
76
+ }
77
+ return matched ?? manifest.flat.get(column);
78
+ }
79
+ /**
80
+ * The column names an insert or update writes, paired with the values going
81
+ * into them.
82
+ *
83
+ * Only literal values are collected. A raw expression or a sub-select is
84
+ * something this plugin cannot read and must not guess at, so it is skipped —
85
+ * the guard's job is to catch a plaintext string a call site forgot to encrypt,
86
+ * not to prove every write correct.
87
+ */
88
+ function collectWrittenValues(node) {
89
+ const written = [];
90
+ const visit = (current) => {
91
+ if (!current || typeof current !== 'object')
92
+ return;
93
+ const op = current;
94
+ if (op.kind === 'ColumnUpdateNode') {
95
+ const name = op.column?.column?.name;
96
+ if (typeof name === 'string' && op.value?.kind === 'ValueNode') {
97
+ written.push([name, op.value.value]);
98
+ }
99
+ }
100
+ if (op.kind === 'InsertQueryNode' && Array.isArray(op.columns)) {
101
+ const names = op.columns.map((c) => c.column?.name);
102
+ const rows = op.values?.values;
103
+ if (Array.isArray(rows)) {
104
+ for (const row of rows) {
105
+ const list = row?.values;
106
+ if (!Array.isArray(list))
107
+ continue;
108
+ list.forEach((value, index) => {
109
+ const name = names[index];
110
+ if (typeof name !== 'string')
111
+ return;
112
+ const unwrapped = value && typeof value === 'object' && 'kind' in value
113
+ ? value.kind ===
114
+ 'ValueNode'
115
+ ? value.value
116
+ : undefined
117
+ : value;
118
+ if (unwrapped !== undefined)
119
+ written.push([name, unwrapped]);
120
+ });
121
+ }
122
+ }
123
+ }
124
+ for (const value of Object.values(current)) {
125
+ if (Array.isArray(value)) {
126
+ for (const item of value)
127
+ visit(item);
128
+ }
129
+ else {
130
+ visit(value);
131
+ }
132
+ }
133
+ };
134
+ visit(node);
135
+ return written;
136
+ }
137
+ /**
138
+ * Make a column's at-rest form a property of the column rather than of the
139
+ * developer's memory.
140
+ *
141
+ * Reads are transparent: a `wrapped` column comes back decrypted, so
142
+ * application code never sees an envelope. `hashed` and `plain` columns are
143
+ * untouched, which is what keeps the rest of the schema queryable.
144
+ *
145
+ * Writes are *guarded* rather than transformed, and the reason is Kysely's
146
+ * shape, not a preference: `transformQuery` is synchronous and WebCrypto is
147
+ * not, so a plugin cannot encrypt on the way in. Encrypting at the call site
148
+ * and letting a forgotten call slip through silently is the failure this
149
+ * design exists to prevent, so the guard refuses any plaintext heading for a
150
+ * `wrapped` or `sealed` column. Values are produced with
151
+ * `ClassificationCrypto.encryptColumn`.
152
+ *
153
+ * A `sealed` column is never decrypted on the way out — the application holds
154
+ * only the public half by definition, so returning it as stored is correct
155
+ * rather than a gap.
156
+ */
157
+ export function createClassificationPlugin(options) {
158
+ const manifest = buildManifest(options.manifest);
159
+ const queryTables = new WeakMap();
160
+ return {
161
+ transformQuery(args) {
162
+ const tables = new Set();
163
+ collectQueryTables(args.node, tables);
164
+ queryTables.set(args.queryId, [...tables]);
165
+ for (const [name, value] of collectWrittenValues(args.node)) {
166
+ if (value === null || value === undefined)
167
+ continue;
168
+ const column = lookupColumn(manifest, [...tables], name);
169
+ if (!column)
170
+ continue;
171
+ if (!isColumnEnvelope(value)) {
172
+ throw new Error(`Refusing to write plaintext into "${name}", which is classified ${column.form}. ` +
173
+ `Produce the value with ClassificationCrypto.encryptColumn() before writing it.`);
174
+ }
175
+ }
176
+ return args.node;
177
+ },
178
+ async transformResult(args) {
179
+ const tables = queryTables.get(args.queryId) ?? [];
180
+ const rows = [];
181
+ for (const row of args.result.rows) {
182
+ let next;
183
+ for (const [name, value] of Object.entries(row)) {
184
+ if (value === null || value === undefined)
185
+ continue;
186
+ const column = lookupColumn(manifest, tables, name);
187
+ if (!column || column.form !== 'wrapped')
188
+ continue;
189
+ if (!isColumnEnvelope(value))
190
+ continue;
191
+ next ??= { ...row };
192
+ next[name] = await options.crypto.decryptColumn(value);
193
+ }
194
+ rows.push(next ?? row);
195
+ }
196
+ return { ...args.result, rows };
197
+ },
198
+ };
199
+ }
@@ -18,6 +18,8 @@ export { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js';
18
18
  export { KyselyVirtualUserScheduleStore } from './kysely-virtual-user-schedule-store.js';
19
19
  export { SerializePlugin, BaseSerializePlugin, type Serializer, type Deserializer, } from './serialize-plugin.js';
20
20
  export { createCoercionPlugin, type ColumnKind, type CoercionMap, type CreateCoercionPluginOptions, } from './coercion-plugin.js';
21
+ export { createClassificationPlugin, type CreateClassificationPluginOptions, } from './classification-plugin.js';
22
+ export { ClassificationCrypto, DEFAULT_KEY_ID, isColumnEnvelope, parseColumnEnvelope, type ClassificationCryptoOptions, type ColumnEnvelope, type KEKResolver, type ResolvedKEK, } from './classification-crypto.js';
21
23
  export { agentSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, type PikkuSchema, type RequiredTypes, type UnmetRequirement, type SchemaStatementFactory, } from './schema/index.js';
22
24
  export type { KyselyPikkuDB } from './kysely-tables.js';
23
25
  export type { WorkflowRunService } from '@pikku/core/workflow';
package/dist/src/index.js CHANGED
@@ -18,4 +18,6 @@ export { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js';
18
18
  export { KyselyVirtualUserScheduleStore } from './kysely-virtual-user-schedule-store.js';
19
19
  export { SerializePlugin, BaseSerializePlugin, } from './serialize-plugin.js';
20
20
  export { createCoercionPlugin, } from './coercion-plugin.js';
21
+ export { createClassificationPlugin, } from './classification-plugin.js';
22
+ export { ClassificationCrypto, DEFAULT_KEY_ID, isColumnEnvelope, parseColumnEnvelope, } from './classification-crypto.js';
21
23
  export { agentSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, } from './schema/index.js';
@@ -0,0 +1,34 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { LockRecord, LockVault } from '@pikku/core/classification';
3
+ import type { KyselyPikkuDB } from './kysely-tables.js';
4
+ /**
5
+ * Lock records kept in the application's own database.
6
+ *
7
+ * This is what makes a sealed column survive a restart. `createMemoryLockVault`
8
+ * is fine for a test, but its salts die with the process and a sealed row whose
9
+ * salt is gone is not recoverable by anyone — so a real deployment stores them
10
+ * next to the rows they protect, in the same backup and the same restore.
11
+ *
12
+ * The table holds no plaintext key material, only salts and verifiers, which is
13
+ * why it is readable while the store is locked.
14
+ */
15
+ export declare class KyselyLockVault implements LockVault {
16
+ private readonly db;
17
+ constructor(db: Kysely<KyselyPikkuDB>);
18
+ read(): Promise<LockRecord[]>;
19
+ /**
20
+ * Replace the whole record set.
21
+ *
22
+ * Whole-set rather than per-row because the callers that write are
23
+ * initialization and rotation, and both produce a complete keyring: applying
24
+ * one as a partial update would leave records from the previous key behind,
25
+ * and a stale salt is indistinguishable from a current one.
26
+ */
27
+ write(records: LockRecord[]): Promise<void>;
28
+ /**
29
+ * Asked through introspection rather than by matching the driver's error
30
+ * text, so the answer does not depend on which engine phrased the failure or
31
+ * on the language it phrased it in.
32
+ */
33
+ private tableExists;
34
+ }
@@ -0,0 +1,77 @@
1
+ import { unsafeAsWrapped } from '@pikku/core/classification';
2
+ /**
3
+ * Lock records kept in the application's own database.
4
+ *
5
+ * This is what makes a sealed column survive a restart. `createMemoryLockVault`
6
+ * is fine for a test, but its salts die with the process and a sealed row whose
7
+ * salt is gone is not recoverable by anyone — so a real deployment stores them
8
+ * next to the rows they protect, in the same backup and the same restore.
9
+ *
10
+ * The table holds no plaintext key material, only salts and verifiers, which is
11
+ * why it is readable while the store is locked.
12
+ */
13
+ export class KyselyLockVault {
14
+ db;
15
+ constructor(db) {
16
+ this.db = db;
17
+ }
18
+ async read() {
19
+ try {
20
+ const rows = await this.db
21
+ .selectFrom('dataLocks')
22
+ .select(['keyId', 'keyVersion', 'salt', 'verifier'])
23
+ .execute();
24
+ return rows.map((row) => ({
25
+ keyId: row.keyId,
26
+ // SQLite hands back whatever it stored and postgres can widen an
27
+ // integer to a string over the wire, while `keyVersion` is compared
28
+ // and stamped as a number.
29
+ keyVersion: Number(row.keyVersion),
30
+ salt: row.salt,
31
+ verifier: unsafeAsWrapped(row.verifier),
32
+ }));
33
+ }
34
+ catch (cause) {
35
+ if (await this.tableExists()) {
36
+ throw cause;
37
+ }
38
+ // No table yet is a legitimate first run — the migration has not been
39
+ // applied, nobody has chosen a passphrase, and the server should come up
40
+ // and offer to initialize rather than refuse to start.
41
+ return [];
42
+ }
43
+ }
44
+ /**
45
+ * Replace the whole record set.
46
+ *
47
+ * Whole-set rather than per-row because the callers that write are
48
+ * initialization and rotation, and both produce a complete keyring: applying
49
+ * one as a partial update would leave records from the previous key behind,
50
+ * and a stale salt is indistinguishable from a current one.
51
+ */
52
+ async write(records) {
53
+ await this.db.transaction().execute(async (trx) => {
54
+ await trx.deleteFrom('dataLocks').execute();
55
+ if (records.length) {
56
+ await trx
57
+ .insertInto('dataLocks')
58
+ .values(records.map((record) => ({
59
+ keyId: record.keyId,
60
+ keyVersion: record.keyVersion,
61
+ salt: record.salt,
62
+ verifier: record.verifier,
63
+ })))
64
+ .execute();
65
+ }
66
+ });
67
+ }
68
+ /**
69
+ * Asked through introspection rather than by matching the driver's error
70
+ * text, so the answer does not depend on which engine phrased the failure or
71
+ * on the language it phrased it in.
72
+ */
73
+ async tableExists() {
74
+ const tables = await this.db.introspection.getTables();
75
+ return tables.some((table) => table.name === 'data_locks');
76
+ }
77
+ }
@@ -15,7 +15,7 @@ export declare class KyselyWebhookService extends QueueWebhookService {
15
15
  private initialized;
16
16
  constructor(queueService: QueueService, db: Kysely<KyselyPikkuDB>);
17
17
  init(): Promise<void>;
18
- send(input: SendWebhookInput): Promise<SendWebhookResult>;
18
+ send(input: SendWebhookInput): Promise<Required<SendWebhookResult>>;
19
19
  recordAttempt(deliveryId: string, { statusCode, responseBody, error, delivered }: WebhookAttemptResult): Promise<void>;
20
20
  /** List deliveries, most recent first — for the console webhooks view. */
21
21
  listDeliveries(opts?: {
@@ -37,7 +37,7 @@ export class KyselyWebhookService extends QueueWebhookService {
37
37
  // deliveryId doubles as the queue jobId (idempotency) and rides in the
38
38
  // payload so the worker can record attempts against this row.
39
39
  const jobId = await this.queueService.add(PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME, { ...jobData, deliveryId }, { ...options, jobId: deliveryId });
40
- return { jobId };
40
+ return { jobId, deliveryId };
41
41
  }
42
42
  async recordAttempt(deliveryId, { statusCode, responseBody, error, delivered }) {
43
43
  const now = new Date();
@@ -0,0 +1,15 @@
1
+ import type { PikkuSchema } from './pikku-schema.types.js';
2
+ /**
3
+ * Where the passphrase's salts and verifiers live.
4
+ *
5
+ * Deliberately the one table in a classified deployment that holds nothing
6
+ * encrypted. It has to be readable before anything has been unlocked — a store
7
+ * that needed its own key to find out how to open itself could never be opened
8
+ * — and that costs nothing, because a salt and a verifier reveal no key
9
+ * material.
10
+ *
11
+ * Losing this table is losing the data: every sealed row is opened by a KEK
12
+ * derived from the salt kept here, so a deployment that ships without the
13
+ * migration silently turns its own rows into noise the first time it restarts.
14
+ */
15
+ export declare const dataLockSchema: PikkuSchema;
@@ -0,0 +1,27 @@
1
+ import { sql } from 'kysely';
2
+ /**
3
+ * Where the passphrase's salts and verifiers live.
4
+ *
5
+ * Deliberately the one table in a classified deployment that holds nothing
6
+ * encrypted. It has to be readable before anything has been unlocked — a store
7
+ * that needed its own key to find out how to open itself could never be opened
8
+ * — and that costs nothing, because a salt and a verifier reveal no key
9
+ * material.
10
+ *
11
+ * Losing this table is losing the data: every sealed row is opened by a KEK
12
+ * derived from the salt kept here, so a deployment that ships without the
13
+ * migration silently turns its own rows into noise the first time it restarts.
14
+ */
15
+ export const dataLockSchema = {
16
+ name: 'data-lock',
17
+ ownedBy: ['dataLock'],
18
+ statements: [
19
+ (db) => db.schema
20
+ .createTable('dataLocks')
21
+ .addColumn('keyId', 'varchar(255)', (col) => col.primaryKey())
22
+ .addColumn('keyVersion', 'integer', (col) => col.notNull())
23
+ .addColumn('salt', 'varchar(64)', (col) => col.notNull())
24
+ .addColumn('verifier', 'text', (col) => col.notNull())
25
+ .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
26
+ ],
27
+ };
@@ -1 +1 @@
1
- {"root":["../src/coercion-plugin.ts","../src/create-audited-kysely.ts","../src/index.ts","../src/kysely-agent-run-scores.ts","../src/kysely-agent-run-service.ts","../src/kysely-agent-run-state-service.ts","../src/kysely-agent-storage-service.ts","../src/kysely-audit-service.ts","../src/kysely-channel-store.ts","../src/kysely-credential-service.ts","../src/kysely-deployment-service.ts","../src/kysely-eventhub-store.ts","../src/kysely-json.ts","../src/kysely-scope-service.ts","../src/kysely-secret-service.ts","../src/kysely-session-store.ts","../src/kysely-tables.ts","../src/kysely-virtual-user-run-store.ts","../src/kysely-virtual-user-schedule-store.ts","../src/kysely-webhook-service.ts","../src/kysely-workflow-mirror.ts","../src/kysely-workflow-run-service.ts","../src/kysely-workflow-service.ts","../src/serialize-plugin.ts","../src/schema/agent.schema.ts","../src/schema/audit.schema.ts","../src/schema/channel.schema.ts","../src/schema/credential.schema.ts","../src/schema/deployment.schema.ts","../src/schema/index.ts","../src/schema/pikku-schema.types.ts","../src/schema/scope.schema.ts","../src/schema/secret.schema.ts","../src/schema/session.schema.ts","../src/schema/virtual-user-schedule.schema.ts","../src/schema/virtual-user.schema.ts","../src/schema/webhook.schema.ts","../src/schema/workflow.schema.ts","../bin/pikku-kysely-pure.ts"],"version":"6.0.3"}
1
+ {"root":["../src/classification-crypto.ts","../src/classification-plugin.ts","../src/coercion-plugin.ts","../src/create-audited-kysely.ts","../src/index.ts","../src/kysely-agent-run-scores.ts","../src/kysely-agent-run-service.ts","../src/kysely-agent-run-state-service.ts","../src/kysely-agent-storage-service.ts","../src/kysely-audit-service.ts","../src/kysely-channel-store.ts","../src/kysely-credential-service.ts","../src/kysely-deployment-service.ts","../src/kysely-eventhub-store.ts","../src/kysely-json.ts","../src/kysely-scope-service.ts","../src/kysely-secret-service.ts","../src/kysely-session-store.ts","../src/kysely-tables.ts","../src/kysely-virtual-user-run-store.ts","../src/kysely-virtual-user-schedule-store.ts","../src/kysely-webhook-service.ts","../src/kysely-workflow-mirror.ts","../src/kysely-workflow-run-service.ts","../src/kysely-workflow-service.ts","../src/serialize-plugin.ts","../src/schema/agent.schema.ts","../src/schema/audit.schema.ts","../src/schema/channel.schema.ts","../src/schema/credential.schema.ts","../src/schema/deployment.schema.ts","../src/schema/index.ts","../src/schema/pikku-schema.types.ts","../src/schema/scope.schema.ts","../src/schema/secret.schema.ts","../src/schema/session.schema.ts","../src/schema/virtual-user-schedule.schema.ts","../src/schema/virtual-user.schema.ts","../src/schema/webhook.schema.ts","../src/schema/workflow.schema.ts","../bin/pikku-kysely-pure.ts"],"version":"6.0.3"}