@cosmicdrift/kumiko-framework 0.113.1 → 1.0.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.
Files changed (76) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/schema-cli.integration.test.ts +0 -29
  3. package/src/api/__tests__/batch.integration.test.ts +1 -4
  4. package/src/api/__tests__/request-id-middleware.test.ts +0 -51
  5. package/src/api/auth-middleware.ts +1 -65
  6. package/src/api/auth-routes.ts +0 -11
  7. package/src/api/index.ts +1 -3
  8. package/src/api/jwt.ts +4 -1
  9. package/src/api/request-context.ts +0 -3
  10. package/src/api/request-id-middleware.ts +0 -2
  11. package/src/api/routes.ts +1 -26
  12. package/src/api/server.ts +1 -29
  13. package/src/bun-db/__tests__/write-brand.test.ts +0 -21
  14. package/src/bun-db/query.ts +0 -17
  15. package/src/db/__tests__/config-seed.integration.test.ts +5 -13
  16. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  17. package/src/db/__tests__/event-store-executor.integration.test.ts +4 -3
  18. package/src/db/__tests__/tenant-db-where-merge.test.ts +1 -4
  19. package/src/db/__tests__/tenant-db.integration.test.ts +2 -6
  20. package/src/db/config-seed.ts +9 -9
  21. package/src/db/encryption.ts +0 -7
  22. package/src/db/entity-field-encryption.ts +24 -81
  23. package/src/db/event-store-executor.ts +24 -37
  24. package/src/db/index.ts +0 -3
  25. package/src/db/query.ts +0 -1
  26. package/src/db/tenant-db.ts +9 -14
  27. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +5 -6
  28. package/src/engine/__tests__/boot-validator.test.ts +9 -33
  29. package/src/engine/__tests__/build-config-feature-schema.test.ts +0 -21
  30. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +0 -6
  31. package/src/engine/__tests__/registry.test.ts +0 -28
  32. package/src/engine/__tests__/screen.test.ts +0 -40
  33. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -3
  34. package/src/engine/boot-validator/gdpr-storage.ts +1 -2
  35. package/src/engine/boot-validator/index.ts +2 -7
  36. package/src/engine/boot-validator/screens-nav.ts +0 -21
  37. package/src/engine/define-feature.ts +0 -17
  38. package/src/engine/define-handler.ts +2 -16
  39. package/src/engine/extensions/user-data.ts +1 -12
  40. package/src/engine/index.ts +2 -5
  41. package/src/engine/registry.ts +21 -44
  42. package/src/engine/soft-delete-cleanup.ts +2 -6
  43. package/src/engine/types/feature.ts +21 -29
  44. package/src/engine/types/fields.ts +1 -3
  45. package/src/engine/types/handlers.ts +2 -16
  46. package/src/engine/types/index.ts +0 -2
  47. package/src/engine/types/projection.ts +0 -22
  48. package/src/engine/types/screen.ts +0 -29
  49. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  50. package/src/event-store/index.ts +0 -8
  51. package/src/files/provider-resolver.ts +15 -28
  52. package/src/i18n/required-surface-keys.ts +0 -12
  53. package/src/observability/__tests__/observability.integration.test.ts +1 -4
  54. package/src/pipeline/dispatcher.ts +6 -2
  55. package/src/pipeline/msp-rebuild.ts +3 -36
  56. package/src/pipeline/projection-rebuild.ts +3 -61
  57. package/src/schema-cli.ts +15 -24
  58. package/src/secrets/__tests__/contains-secret.test.ts +0 -34
  59. package/src/secrets/dek-cache.ts +5 -26
  60. package/src/secrets/envelope.ts +3 -5
  61. package/src/secrets/index.ts +1 -13
  62. package/src/secrets/types.ts +3 -18
  63. package/src/testing/e2e-generator.ts +0 -3
  64. package/src/testing/index.ts +0 -2
  65. package/src/testing/mutable-master-key-provider.ts +3 -29
  66. package/src/time/__tests__/tz-dateline.test.ts +10 -8
  67. package/src/ui-types/index.ts +0 -1
  68. package/src/api/__tests__/pat-scope.test.ts +0 -36
  69. package/src/api/pat-scope.ts +0 -14
  70. package/src/engine/__tests__/extend-entity-projection.test.ts +0 -123
  71. package/src/event-store/rebuild-dead-letter.ts +0 -111
  72. package/src/files/__tests__/provider-resolver.test.ts +0 -70
  73. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +0 -274
  74. package/src/secrets/__tests__/envelope-cipher.test.ts +0 -87
  75. package/src/secrets/envelope-cipher.ts +0 -81
  76. package/src/secrets/stored-envelope.ts +0 -46
@@ -1,274 +0,0 @@
1
- // #760 — poison-event quarantine during projection rebuild.
2
- //
3
- // A single historical event whose apply throws used to abort the WHOLE
4
- // rebuild (tx rollback, status failed) with no recovery path short of
5
- // hand-editing the event. With quarantine mode the rebuild confines each
6
- // apply to a savepoint: the poison event is skipped, recorded into
7
- // kumiko_rebuild_dead_letters, and the replay completes.
8
- //
9
- // Covers both rebuild flavors:
10
- // - single-stream: RebuildDeps.errorPolicy.skipApplyErrors (per run)
11
- // - MSP: MspErrorMode.rebuild.skipApplyErrors (per definition — declared
12
- // API that was previously never honored by rebuildMultiStreamProjection)
13
- // and both poison flavors:
14
- // - JS throw (no SQL executed)
15
- // - SQL error (aborts the tx → proves the savepoint is load-bearing)
16
-
17
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
18
- import { z } from "zod";
19
- import { integer as pgInteger, table as pgTable, uuid as pgUuid } from "../../db/dialect";
20
- import { createEventStoreExecutor } from "../../db/event-store-executor";
21
- import { asRawClient } from "../../db/query";
22
- import { buildEntityTable } from "../../db/table-builder";
23
- import { createTenantDb, type TenantDb } from "../../db/tenant-db";
24
- import { createEntity, createTextField, defineApply, defineFeature } from "../../engine";
25
- import type { ProjectionDefinition } from "../../engine/types";
26
- import { append, createEventsTable } from "../../event-store";
27
- import { listRebuildDeadLetters } from "../../event-store/rebuild-dead-letter";
28
- import {
29
- createProjectionStateTable,
30
- getConsumerState,
31
- getProjectionState,
32
- rebuildMultiStreamProjection,
33
- rebuildProjection,
34
- } from "../../pipeline";
35
- import {
36
- setupTestStack,
37
- type TestStack,
38
- TestUsers,
39
- unsafeCreateEntityTable,
40
- unsafePushTables,
41
- } from "../../stack";
42
-
43
- // --- Fixtures ---
44
-
45
- const POISON_JS = "poison-js";
46
- const POISON_SQL = "poison-sql";
47
-
48
- const itemEntity = createEntity({
49
- table: "read_poison_items",
50
- fields: {
51
- groupId: createTextField({ required: true }),
52
- name: createTextField({ required: true }),
53
- },
54
- });
55
- const itemTable = buildEntityTable("poison-item", itemEntity);
56
-
57
- const counterTable = pgTable("read_poison_counter", {
58
- groupId: pgUuid("group_id").primaryKey(),
59
- tenantId: pgUuid("tenant_id").notNull(),
60
- itemCount: pgInteger("item_count").notNull().default(0),
61
- });
62
-
63
- const mspCounterTable = pgTable("read_poison_msp_counter", {
64
- groupId: pgUuid("group_id").primaryKey(),
65
- tenantId: pgUuid("tenant_id").notNull(),
66
- itemCount: pgInteger("item_count").notNull().default(0),
67
- });
68
-
69
- async function bump(tx: unknown, tableName: string, groupId: string, tenantId: string) {
70
- await asRawClient(tx).unsafe(
71
- `INSERT INTO "${tableName}" (group_id, tenant_id, item_count) VALUES ($1::uuid, $2::uuid, 1) ON CONFLICT (group_id) DO UPDATE SET item_count = "${tableName}".item_count + 1`,
72
- [groupId, tenantId],
73
- );
74
- }
75
-
76
- // Poison by payload marker. POISON_SQL runs a genuinely failing statement so
77
- // the surrounding tx would be in 25P02 without the savepoint.
78
- async function applyOrPoison(
79
- tx: unknown,
80
- tableName: string,
81
- payload: { groupId: string; name: string },
82
- tenantId: string,
83
- ): Promise<void> {
84
- if (payload.name === POISON_JS) throw new Error("intentional js poison");
85
- if (payload.name === POISON_SQL) {
86
- await asRawClient(tx).unsafe(
87
- `INSERT INTO "${tableName}" (group_id, tenant_id, item_count) VALUES ('not-a-uuid', $1::uuid, 1)`,
88
- [tenantId],
89
- );
90
- return;
91
- }
92
- await bump(tx, tableName, payload.groupId, tenantId);
93
- }
94
-
95
- type ItemPayload = { groupId: string; name: string };
96
-
97
- const poisonProjection: ProjectionDefinition = {
98
- name: "poison-counter",
99
- source: "poison-item",
100
- table: counterTable,
101
- apply: {
102
- "poison-item.created": defineApply<ItemPayload>(async (event, tx) => {
103
- await applyOrPoison(tx, "read_poison_counter", event.payload, event.tenantId);
104
- }),
105
- },
106
- };
107
-
108
- const MSP_EVENT_SHORT = "poison-noted";
109
-
110
- const feature = defineFeature("poisontest", (r) => {
111
- r.entity("poison-item", itemEntity);
112
- r.projection(poisonProjection);
113
- const noted = r.defineEvent(MSP_EVENT_SHORT, z.object({ groupId: z.uuid(), name: z.string() }));
114
- r.multiStreamProjection({
115
- name: "poison-msp-counter",
116
- table: mspCounterTable,
117
- errorMode: { rebuild: { skipApplyErrors: true } },
118
- apply: {
119
- [noted.name]: async (event, tx) => {
120
- const p = event.payload as ItemPayload; // @cast-boundary engine-payload
121
- await applyOrPoison(tx, "read_poison_msp_counter", p, event.tenantId);
122
- },
123
- },
124
- });
125
- });
126
-
127
- const admin = TestUsers.admin;
128
- const PROJECTION = "poisontest:projection:poison-counter";
129
- const MSP = "poisontest:projection:poison-msp-counter";
130
- const MSP_EVENT = "poisontest:event:poison-noted";
131
- const GROUP = "00000000-0000-4000-8000-00000000cafe";
132
-
133
- let stack: TestStack;
134
- let tdb: TenantDb;
135
-
136
- const executor = createEventStoreExecutor(itemTable, itemEntity, { entityName: "poison-item" });
137
-
138
- beforeAll(async () => {
139
- stack = await setupTestStack({ features: [feature] });
140
- await unsafeCreateEntityTable(stack.db, itemEntity, "poison-item");
141
- await createEventsTable(stack.db);
142
- await createProjectionStateTable(stack.db);
143
- await unsafePushTables(stack.db, {
144
- poisonCounter: counterTable,
145
- poisonMspCounter: mspCounterTable,
146
- });
147
- tdb = createTenantDb(stack.db, admin.tenantId);
148
- });
149
-
150
- afterAll(async () => {
151
- await stack.cleanup();
152
- });
153
-
154
- beforeEach(async () => {
155
- await asRawClient(stack.db).unsafe(
156
- `TRUNCATE kumiko_events, read_poison_items, read_poison_counter, read_poison_msp_counter, kumiko_projections RESTART IDENTITY CASCADE`,
157
- );
158
- await asRawClient(stack.db).unsafe(`DROP TABLE IF EXISTS kumiko_rebuild_dead_letters`);
159
- if (stack.eventDispatcher) await stack.eventDispatcher.ensureRegistered();
160
- });
161
-
162
- async function createItem(name: string): Promise<void> {
163
- await executor.create({ groupId: GROUP, name }, admin, tdb);
164
- }
165
-
166
- async function getCount(table: string): Promise<number | undefined> {
167
- const rows = (await asRawClient(stack.db).unsafe(
168
- `SELECT item_count FROM "${table}" WHERE group_id = $1::uuid`,
169
- [GROUP],
170
- )) as ReadonlyArray<{ item_count: number }>;
171
- return rows[0]?.item_count;
172
- }
173
-
174
- describe("rebuildProjection — poison event, strict default (#760)", () => {
175
- test("apply throw aborts the rebuild: status failed, no dead letters", async () => {
176
- await createItem("good-1");
177
- await createItem(POISON_JS);
178
- await createItem("good-2");
179
-
180
- await expect(
181
- rebuildProjection(PROJECTION, { db: stack.db, registry: stack.registry }),
182
- ).rejects.toThrow("intentional js poison");
183
-
184
- const state = await getProjectionState(stack.db, PROJECTION);
185
- expect(state?.status).toBe("failed");
186
- // Strict mode never provisions/records dead letters.
187
- const rows = (await asRawClient(stack.db).unsafe(
188
- `SELECT to_regclass('public.kumiko_rebuild_dead_letters') AS t`,
189
- )) as ReadonlyArray<{ t: string | null }>;
190
- expect(rows[0]?.t).toBeNull();
191
- });
192
- });
193
-
194
- describe("rebuildProjection — quarantine mode (#760)", () => {
195
- test("js- and sql-poison events are skipped, recorded, and the rebuild completes", async () => {
196
- await createItem("good-1");
197
- await createItem(POISON_JS);
198
- // sql-poison proves the savepoint: without it the failed statement puts
199
- // the whole rebuild tx into 25P02 and every later apply fails too.
200
- await createItem(POISON_SQL);
201
- await createItem("good-2");
202
-
203
- const result = await rebuildProjection(PROJECTION, {
204
- db: stack.db,
205
- registry: stack.registry,
206
- errorPolicy: { skipApplyErrors: true },
207
- });
208
-
209
- expect(result.eventsSkipped).toBe(2);
210
- expect(result.eventsProcessed).toBe(4);
211
- expect(await getCount("read_poison_counter")).toBe(2);
212
-
213
- const state = await getProjectionState(stack.db, PROJECTION);
214
- expect(state?.status).toBe("idle");
215
-
216
- const deadLetters = await listRebuildDeadLetters(stack.db, { projectionName: PROJECTION });
217
- expect(deadLetters).toHaveLength(2);
218
- const messages = deadLetters.map((d) => d.errorMessage).sort();
219
- expect(messages[0]).toContain("poison");
220
- expect(deadLetters.every((d) => d.eventType === "poison-item.created")).toBe(true);
221
- expect(deadLetters.every((d) => d.aggregateType === "poison-item")).toBe(true);
222
- });
223
-
224
- test("clean run in quarantine mode: zero skipped, no dead letters", async () => {
225
- await createItem("good-1");
226
- await createItem("good-2");
227
-
228
- const result = await rebuildProjection(PROJECTION, {
229
- db: stack.db,
230
- registry: stack.registry,
231
- errorPolicy: { skipApplyErrors: true },
232
- });
233
-
234
- expect(result.eventsSkipped).toBe(0);
235
- expect(await getCount("read_poison_counter")).toBe(2);
236
- expect(await listRebuildDeadLetters(stack.db, { projectionName: PROJECTION })).toHaveLength(0);
237
- });
238
- });
239
-
240
- describe("rebuildMultiStreamProjection — errorMode.rebuild.skipApplyErrors (#760)", () => {
241
- async function appendMspEvent(name: string, expectedVersion: number): Promise<void> {
242
- await append(stack.db, {
243
- aggregateId: GROUP,
244
- aggregateType: "poison-note",
245
- tenantId: admin.tenantId,
246
- expectedVersion,
247
- type: MSP_EVENT,
248
- payload: { groupId: GROUP, name },
249
- metadata: { userId: admin.id },
250
- });
251
- }
252
-
253
- test("declared rebuild policy is honored: poison skipped + recorded, cursor advanced", async () => {
254
- await appendMspEvent("good-1", 0);
255
- await appendMspEvent(POISON_SQL, 1);
256
- await appendMspEvent("good-2", 2);
257
-
258
- const result = await rebuildMultiStreamProjection(MSP, {
259
- db: stack.db,
260
- registry: stack.registry,
261
- });
262
-
263
- expect(result.eventsSkipped).toBe(1);
264
- expect(result.eventsProcessed).toBe(3);
265
- expect(await getCount("read_poison_msp_counter")).toBe(2);
266
-
267
- const deadLetters = await listRebuildDeadLetters(stack.db, { projectionName: MSP });
268
- expect(deadLetters).toHaveLength(1);
269
- expect(deadLetters[0]?.eventType).toBe(MSP_EVENT);
270
-
271
- const consumer = await getConsumerState(stack.db, MSP);
272
- expect(consumer?.lastProcessedEventId).toBe(result.lastProcessedEventId);
273
- });
274
- });
@@ -1,87 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { randomBytes } from "node:crypto";
3
- import { createEncryptionProvider } from "../../db/encryption";
4
- import { createEnvMasterKeyProvider } from "../env-master-key-provider";
5
- import { createEnvelopeCipher } from "../envelope-cipher";
6
- import { isStoredEnvelope } from "../stored-envelope";
7
-
8
- function makeProvider(currentVersion = 1) {
9
- return createEnvMasterKeyProvider({
10
- env: {
11
- KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: String(currentVersion),
12
- [`KUMIKO_SECRETS_MASTER_KEY_V${currentVersion}`]: randomBytes(32).toString("base64"),
13
- },
14
- });
15
- }
16
-
17
- describe("envelope-cipher — encrypt/decrypt", () => {
18
- test("round-trips plaintext through the JSON envelope format", async () => {
19
- const cipher = createEnvelopeCipher(makeProvider());
20
- const stored = await cipher.encrypt("s3cret-smtp-pass");
21
-
22
- expect(stored.startsWith("{")).toBe(true);
23
- const parsed: unknown = JSON.parse(stored);
24
- expect(isStoredEnvelope(parsed)).toBe(true);
25
-
26
- expect(await cipher.decrypt(stored)).toBe("s3cret-smtp-pass");
27
- });
28
-
29
- test("stored value carries the kekVersion for later rotation", async () => {
30
- const cipher = createEnvelopeCipher(makeProvider(7));
31
- const stored = await cipher.encrypt("x");
32
- expect((JSON.parse(stored) as { kekVersion: number }).kekVersion).toBe(7);
33
- });
34
-
35
- test("tampered ciphertext fails GCM authentication", async () => {
36
- const cipher = createEnvelopeCipher(makeProvider());
37
- const stored = JSON.parse(await cipher.encrypt("payload")) as Record<string, unknown>;
38
- const cipherBytes = Buffer.from(stored["ciphertext"] as string, "base64");
39
- cipherBytes[0] = (cipherBytes[0] ?? 0) ^ 0xff;
40
- const tampered = JSON.stringify({ ...stored, ciphertext: cipherBytes.toString("base64") });
41
- await expect(cipher.decrypt(tampered)).rejects.toThrow();
42
- });
43
- });
44
-
45
- describe("envelope-cipher — legacy fallback", () => {
46
- test("decrypts legacy single-key values when the legacy provider is configured", async () => {
47
- const legacy = createEncryptionProvider(randomBytes(32).toString("base64"));
48
- const legacyStored = legacy.encrypt("pre-envelope value");
49
- // legacy wire format is base64 — never starts with "{"
50
- expect(legacyStored.startsWith("{")).toBe(false);
51
-
52
- const cipher = createEnvelopeCipher(makeProvider(), { legacy });
53
- expect(await cipher.decrypt(legacyStored)).toBe("pre-envelope value");
54
- });
55
-
56
- test("throws with a remediation message when a legacy value has no legacy key", async () => {
57
- const legacy = createEncryptionProvider(randomBytes(32).toString("base64"));
58
- const legacyStored = legacy.encrypt("orphaned");
59
-
60
- const cipher = createEnvelopeCipher(makeProvider());
61
- await expect(cipher.decrypt(legacyStored)).rejects.toThrow(/legacy/);
62
- });
63
-
64
- test("never encrypts into the legacy format even when a legacy key is present", async () => {
65
- const legacy = createEncryptionProvider(randomBytes(32).toString("base64"));
66
- const cipher = createEnvelopeCipher(makeProvider(), { legacy });
67
- const stored = await cipher.encrypt("always-envelope");
68
- expect(stored.startsWith("{")).toBe(true);
69
- });
70
- });
71
-
72
- describe("envelope-cipher — malformed input", () => {
73
- test("rejects invalid JSON that looks like an envelope", async () => {
74
- const cipher = createEnvelopeCipher(makeProvider());
75
- await expect(cipher.decrypt("{garbage")).rejects.toThrow(/not valid JSON/);
76
- });
77
-
78
- test("rejects JSON that is not a StoredEnvelope", async () => {
79
- const cipher = createEnvelopeCipher(makeProvider());
80
- await expect(cipher.decrypt('{"foo":"bar"}')).rejects.toThrow(/not a StoredEnvelope/);
81
- });
82
-
83
- test("empty string routes to the legacy branch and throws without a legacy key", async () => {
84
- const cipher = createEnvelopeCipher(makeProvider());
85
- await expect(cipher.decrypt("")).rejects.toThrow(/legacy/);
86
- });
87
- });
@@ -1,81 +0,0 @@
1
- // String-in/string-out envelope encryption for TEXT-column stores (config
2
- // values, entity fields). The stored string is JSON of StoredEnvelope; the
3
- // kekVersion inside makes every value rotatable via the MasterKeyProvider
4
- // keyring — unlike the legacy createEncryptionProvider format (raw
5
- // base64(iv+tag+ct), no key id), which this cipher still DECRYPTS through
6
- // the optional legacy provider so pre-envelope rows stay readable until a
7
- // re-encrypt job has migrated them.
8
-
9
- import type { EncryptionProvider } from "../db/encryption";
10
- import { InternalError } from "../errors/classes";
11
- import type { DekCache } from "./dek-cache";
12
- import { createDekCache, withDekCache } from "./dek-cache";
13
- import { decryptValue, encryptValue } from "./envelope";
14
- import { decodeStoredEnvelope, encodeStoredEnvelope, isStoredEnvelope } from "./stored-envelope";
15
- import type { KeyScope, MasterKeyProvider } from "./types";
16
-
17
- export type EnvelopeCipherOptions = {
18
- // Decrypt-only fallback for legacy createEncryptionProvider ciphertexts
19
- // (CONFIG_ENCRYPTION_KEY / ENCRYPTION_KEY era). Never used for encrypt.
20
- readonly legacy?: EncryptionProvider;
21
- // Shared DEK cache — pass the app-wide instance so config/entity reads
22
- // amortise KEK unwraps together with ctx.secrets.
23
- readonly dekCache?: DekCache;
24
- };
25
-
26
- export type EnvelopeCipher = {
27
- encrypt(plaintext: string, scope?: KeyScope): Promise<string>;
28
- decrypt(stored: string, scope?: KeyScope): Promise<string>;
29
- };
30
-
31
- // Format detection: envelope values are JSON objects, so they start with
32
- // "{" — a character the base64 alphabet of the legacy format can never
33
- // produce. No version byte or prefix marker needed.
34
- function isEnvelopeFormat(stored: string): boolean {
35
- return stored.startsWith("{");
36
- }
37
-
38
- export function createEnvelopeCipher(
39
- provider: MasterKeyProvider,
40
- opts: EnvelopeCipherOptions = {},
41
- ): EnvelopeCipher {
42
- const cached = withDekCache(provider, opts.dekCache ?? createDekCache());
43
-
44
- return {
45
- async encrypt(plaintext, scope) {
46
- const envelope = await encryptValue(plaintext, provider, scope);
47
- return JSON.stringify(encodeStoredEnvelope(envelope));
48
- },
49
-
50
- async decrypt(stored, scope) {
51
- if (isEnvelopeFormat(stored)) {
52
- let parsed: unknown;
53
- try {
54
- parsed = JSON.parse(stored);
55
- } catch {
56
- throw new InternalError({
57
- message: "[envelope-cipher] stored value looks like an envelope but is not valid JSON",
58
- i18nKey: "secrets.errors.envelope_malformed",
59
- });
60
- }
61
- if (!isStoredEnvelope(parsed)) {
62
- throw new InternalError({
63
- message: "[envelope-cipher] stored JSON is not a StoredEnvelope",
64
- i18nKey: "secrets.errors.envelope_malformed",
65
- });
66
- }
67
- return decryptValue(decodeStoredEnvelope(parsed), cached, scope);
68
- }
69
-
70
- if (!opts.legacy) {
71
- throw new InternalError({
72
- message:
73
- "[envelope-cipher] value is in the legacy single-key format but no legacy key is configured — " +
74
- "provision the legacy key (CONFIG_ENCRYPTION_KEY / ENCRYPTION_KEY) or run the re-encrypt job first",
75
- i18nKey: "secrets.errors.legacy_key_missing",
76
- });
77
- }
78
- return opts.legacy.decrypt(stored);
79
- },
80
- };
81
- }
@@ -1,46 +0,0 @@
1
- // Canonical at-rest form of an Envelope: every Buffer as base64, ready for
2
- // jsonb columns (secrets feature) or JSON-stringified TEXT columns
3
- // (EnvelopeCipher for config values / entity fields). One wire shape for
4
- // every envelope-encrypted store in the framework.
5
-
6
- import type { Envelope } from "./types";
7
-
8
- export type StoredEnvelope = {
9
- readonly ciphertext: string; // base64
10
- readonly iv: string; // base64
11
- readonly authTag: string; // base64
12
- readonly encryptedDek: string; // base64
13
- readonly kekVersion: number;
14
- };
15
-
16
- export function encodeStoredEnvelope(envelope: Envelope): StoredEnvelope {
17
- return {
18
- ciphertext: envelope.ciphertext.toString("base64"),
19
- iv: envelope.iv.toString("base64"),
20
- authTag: envelope.authTag.toString("base64"),
21
- encryptedDek: envelope.encryptedDek.toString("base64"),
22
- kekVersion: envelope.kekVersion,
23
- };
24
- }
25
-
26
- export function decodeStoredEnvelope(stored: StoredEnvelope): Envelope {
27
- return {
28
- ciphertext: Buffer.from(stored.ciphertext, "base64"),
29
- iv: Buffer.from(stored.iv, "base64"),
30
- authTag: Buffer.from(stored.authTag, "base64"),
31
- encryptedDek: Buffer.from(stored.encryptedDek, "base64"),
32
- kekVersion: stored.kekVersion,
33
- };
34
- }
35
-
36
- export function isStoredEnvelope(value: unknown): value is StoredEnvelope {
37
- if (!value || typeof value !== "object") return false;
38
- const v = value as Record<string, unknown>; // @cast-boundary parse-boundary after typeof check
39
- return (
40
- typeof v["ciphertext"] === "string" &&
41
- typeof v["iv"] === "string" &&
42
- typeof v["authTag"] === "string" &&
43
- typeof v["encryptedDek"] === "string" &&
44
- typeof v["kekVersion"] === "number"
45
- );
46
- }