Test env NATS cluster cut-over
This commit is contained in:
commit
e3c7b2833b
71 changed files with 6840 additions and 0 deletions
208
src/store/blobs.js
Normal file
208
src/store/blobs.js
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* Mock encrypted long-term object store + share grants.
|
||||
* @module store/blobs
|
||||
*/
|
||||
|
||||
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { getStore, persist } from './db.js';
|
||||
|
||||
const ALGO = 'aes-256-gcm';
|
||||
|
||||
function ensure() {
|
||||
const store = getStore();
|
||||
store.blobs ??= {};
|
||||
store.shares ??= {};
|
||||
store.trees ??= {};
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer} plaintext
|
||||
* @param {Buffer} key 32 bytes
|
||||
*/
|
||||
function encrypt(plaintext, key) {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(ALGO, key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return { iv: iv.toString('base64'), tag: tag.toString('base64'), ciphertext: ciphertext.toString('base64') };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ iv: string, tag: string, ciphertext: string }} enc
|
||||
* @param {Buffer} key
|
||||
*/
|
||||
export function decryptBlob(enc, key) {
|
||||
const decipher = createDecipheriv(ALGO, key, Buffer.from(enc.iv, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(enc.tag, 'base64'));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(enc.ciphertext, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {string} params.tenantId
|
||||
* @param {string} [params.sha256]
|
||||
* @param {string} [params.filename]
|
||||
* @param {Buffer} params.bytes
|
||||
* @param {object} [params.publicMetadata]
|
||||
* @param {object} [params.privateMetadata]
|
||||
*/
|
||||
export function putBlob({ tenantId, sha256, filename, bytes, publicMetadata = {}, privateMetadata = {} }) {
|
||||
const store = ensure();
|
||||
const id = randomUUID();
|
||||
const key = randomBytes(32);
|
||||
const enc = encrypt(bytes, key);
|
||||
store.blobs[id] = {
|
||||
id,
|
||||
tenantId,
|
||||
sha256: sha256 ?? null,
|
||||
filename: filename ?? 'object.bin',
|
||||
size: bytes.length,
|
||||
enc,
|
||||
dek: key.toString('base64'),
|
||||
publicMetadata,
|
||||
privateMetadata,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
persist();
|
||||
return { id, sha256: store.blobs[id].sha256, size: bytes.length, filename: store.blobs[id].filename };
|
||||
}
|
||||
|
||||
export function getBlobRecord(id) {
|
||||
return ensure().blobs[id] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt for owner tenant only.
|
||||
* @param {string} id
|
||||
* @param {string} tenantId
|
||||
*/
|
||||
export function readBlobPlaintext(id, tenantId) {
|
||||
const rec = getBlobRecord(id);
|
||||
if (!rec) return null;
|
||||
if (rec.tenantId !== tenantId) {
|
||||
const err = new Error('Forbidden');
|
||||
err.code = 'FORBIDDEN';
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
...rec,
|
||||
bytes: decryptBlob(rec.enc, Buffer.from(rec.dek, 'base64')),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ blobId: string, fromTenantId: string, label?: string }} params
|
||||
*/
|
||||
export function createShare({ blobId, fromTenantId, label }) {
|
||||
const rec = getBlobRecord(blobId);
|
||||
if (!rec || rec.tenantId !== fromTenantId) return null;
|
||||
const token = `shr_${randomBytes(16).toString('hex')}`;
|
||||
const store = ensure();
|
||||
store.shares[token] = {
|
||||
token,
|
||||
kind: 'file',
|
||||
blobId,
|
||||
fromTenantId,
|
||||
label: label ?? rec.filename,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
persist();
|
||||
return { token, blobId, label: store.shares[token].label };
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem a share token: ciphertext + wrapped key for the recipient.
|
||||
* @param {string} token
|
||||
*/
|
||||
export function redeemShare(token) {
|
||||
const store = ensure();
|
||||
const share = store.shares[token];
|
||||
if (!share) return null;
|
||||
if (share.kind === 'tree') {
|
||||
const tree = store.trees[share.treeId];
|
||||
if (!tree) return null;
|
||||
return {
|
||||
token,
|
||||
kind: 'tree',
|
||||
prefix: tree.prefix,
|
||||
files: tree.entries.map((e) => {
|
||||
const rec = store.blobs[e.blobId];
|
||||
return rec
|
||||
? {
|
||||
path: e.path,
|
||||
blobId: rec.id,
|
||||
filename: rec.filename,
|
||||
size: rec.size,
|
||||
sha256: rec.sha256,
|
||||
enc: rec.enc,
|
||||
dek: rec.dek,
|
||||
}
|
||||
: { path: e.path, missing: true };
|
||||
}),
|
||||
note: 'Mock tree share: each file includes dek. Production must wrap per-file deks to recipient pubkey.',
|
||||
};
|
||||
}
|
||||
const rec = store.blobs[share.blobId];
|
||||
if (!rec) return null;
|
||||
return {
|
||||
token,
|
||||
kind: 'file',
|
||||
blobId: rec.id,
|
||||
filename: rec.filename,
|
||||
size: rec.size,
|
||||
sha256: rec.sha256,
|
||||
publicMetadata: rec.publicMetadata ?? {},
|
||||
enc: rec.enc,
|
||||
dek: rec.dek,
|
||||
note: 'Mock share: dek is included so the recipient can decrypt. Production must wrap dek to recipient pubkey.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ tenantId: string, prefix: string, files: Array<{ path: string, bytes: Buffer, sha256?: string }> }} params
|
||||
*/
|
||||
export function putTree({ tenantId, prefix, files }) {
|
||||
const store = ensure();
|
||||
const id = randomUUID();
|
||||
const entries = [];
|
||||
for (const file of files) {
|
||||
const stored = putBlob({
|
||||
tenantId,
|
||||
sha256: file.sha256,
|
||||
filename: file.path.split('/').pop(),
|
||||
bytes: file.bytes,
|
||||
});
|
||||
entries.push({ path: file.path, blobId: stored.id });
|
||||
}
|
||||
store.trees[id] = { id, tenantId, prefix: prefix || '/', entries, createdAt: new Date().toISOString() };
|
||||
persist();
|
||||
return { id, prefix: store.trees[id].prefix, fileCount: entries.length };
|
||||
}
|
||||
|
||||
export function getTree(id) {
|
||||
return ensure().trees[id] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ treeId: string, fromTenantId: string, label?: string }} params
|
||||
*/
|
||||
export function createTreeShare({ treeId, fromTenantId, label }) {
|
||||
const tree = getTree(treeId);
|
||||
if (!tree || tree.tenantId !== fromTenantId) return null;
|
||||
const token = `shr_${randomBytes(16).toString('hex')}`;
|
||||
const store = ensure();
|
||||
store.shares[token] = {
|
||||
token,
|
||||
kind: 'tree',
|
||||
treeId,
|
||||
fromTenantId,
|
||||
label: label ?? tree.prefix,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
persist();
|
||||
return { token, kind: 'tree', treeId, label: store.shares[token].label, fileCount: tree.entries.length };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue