thatcher 1.0.77 → 1.0.79
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/package.json
CHANGED
|
@@ -196,7 +196,9 @@ export async function list(entity, where = {}, options = {}) {
|
|
|
196
196
|
const lim = options.limit ? parseInt(options.limit, 10) : rows.length;
|
|
197
197
|
rows = rows.slice(off, off + lim);
|
|
198
198
|
}
|
|
199
|
-
|
|
199
|
+
const { decryptFields } = await import('../field-encryption.js');
|
|
200
|
+
const decryptedRows = rows.map(r => decryptFields(r, spec.fields));
|
|
201
|
+
return attachRefDisplays(entity, decryptedRows);
|
|
200
202
|
}
|
|
201
203
|
|
|
202
204
|
export async function count(entity, where = {}, options = {}) {
|
|
@@ -227,7 +229,9 @@ export async function get(entity, id, options = {}) {
|
|
|
227
229
|
const { permissionService } = await import('../services/permission.service.js');
|
|
228
230
|
if (!permissionService.checkRowAccess(options.user, spec, row)) return null;
|
|
229
231
|
}
|
|
230
|
-
const
|
|
232
|
+
const { decryptFields } = await import('../field-encryption.js');
|
|
233
|
+
const decrypted = decryptFields(row, spec.fields);
|
|
234
|
+
const [withDisplay] = await attachRefDisplays(entity, [decrypted]);
|
|
231
235
|
return withDisplay;
|
|
232
236
|
}
|
|
233
237
|
|
|
@@ -255,15 +259,23 @@ export async function create(entity, data, user) {
|
|
|
255
259
|
if (field.auto === 'uuid' && !record[key]) record[key] = genId();
|
|
256
260
|
if (field.auto === 'timestamp' && !record[key]) record[key] = now();
|
|
257
261
|
}
|
|
262
|
+
// Encrypt any field marked encrypted:true BEFORE the null/undefined
|
|
263
|
+
// sentinel coercion below, so a real value never reaches the insert as
|
|
264
|
+
// plaintext -- this is the single choke point every create() call passes
|
|
265
|
+
// through, so no caller needs to know the field is encrypted at all.
|
|
266
|
+
const { encryptFields } = await import('../field-encryption.js');
|
|
267
|
+
const encryptedRecord = encryptFields(record, spec.fields);
|
|
258
268
|
// LanceDB cannot infer a column's type from a null on first insert; coerce any
|
|
259
269
|
// null/undefined to a typed sentinel ('' for strings) so the insert schema is stable.
|
|
260
|
-
for (const k of Object.keys(
|
|
261
|
-
if (
|
|
270
|
+
for (const k of Object.keys(encryptedRecord)) {
|
|
271
|
+
if (encryptedRecord[k] === null || encryptedRecord[k] === undefined) encryptedRecord[k] = '';
|
|
262
272
|
}
|
|
263
|
-
unwrap(await client().from(tbl).insert(
|
|
273
|
+
unwrap(await client().from(tbl).insert(encryptedRecord), 'create');
|
|
264
274
|
// Always return the locally-constructed record: it holds the genId we put in
|
|
265
275
|
// the TEXT id column. The store's insert() may return a rowid/driver shape, so
|
|
266
|
-
// trusting `created` would hand callers the wrong id.
|
|
276
|
+
// trusting `created` would hand callers the wrong id. Return the UNENCRYPTED
|
|
277
|
+
// record -- the caller gets back plaintext, matching what get()/list() will
|
|
278
|
+
// also return after decrypting the stored ciphertext.
|
|
267
279
|
return record;
|
|
268
280
|
}
|
|
269
281
|
|
|
@@ -284,14 +296,19 @@ export async function create(entity, data, user) {
|
|
|
284
296
|
// unconditional-write behaviour, unchanged for every existing caller; _version
|
|
285
297
|
// is added as a new column, ignored by every reader that doesn't ask for it.
|
|
286
298
|
export async function update(entity, id, data, opts = {}) {
|
|
287
|
-
specOf(entity);
|
|
299
|
+
const spec = specOf(entity);
|
|
288
300
|
const tbl = tableName(entity);
|
|
289
301
|
|
|
290
302
|
const existing = await get(entity, id);
|
|
291
303
|
if (!existing) throw new Error(`${entity} with id ${id} not found`);
|
|
292
304
|
|
|
293
305
|
const currentVersion = Number(existing._version) || 0;
|
|
294
|
-
const
|
|
306
|
+
const { encryptFields } = await import('../field-encryption.js');
|
|
307
|
+
// Encrypt only the fields actually present in this partial update -- a
|
|
308
|
+
// caller updating an unrelated field must not force-decrypt/re-encrypt an
|
|
309
|
+
// encrypted field it never touched (encryptFields already skips fields
|
|
310
|
+
// absent from the object, so this is naturally a no-op for those).
|
|
311
|
+
const patch = encryptFields({ ...data, updated_at: now(), _version: currentVersion + 1 }, spec.fields);
|
|
295
312
|
let builder = client().from(tbl).update(patch).eq('id', id);
|
|
296
313
|
if (opts.expectedVersion != null) {
|
|
297
314
|
builder = builder.eq('_version', opts.expectedVersion);
|
|
@@ -253,6 +253,27 @@ function withTimeTrackingDefaults(masterConfig) {
|
|
|
253
253
|
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
const RESOURCE_ALLOCATION_ENTITY_DEFAULT = {
|
|
257
|
+
label: 'Resource Allocation',
|
|
258
|
+
label_plural: 'Resource Allocations',
|
|
259
|
+
system_entity: true,
|
|
260
|
+
fields: {
|
|
261
|
+
user_id: { type: 'ref', ref: 'user', required: true, label: 'User' },
|
|
262
|
+
project_id: { type: 'ref', ref: 'project', required: true, label: 'Project' },
|
|
263
|
+
allocated_hours_per_week: { type: 'number', required: true, min: 0, max: 168, label: 'Allocated Hours/Week' },
|
|
264
|
+
start_date: { type: 'date', required: true, label: 'Start Date' },
|
|
265
|
+
end_date: { type: 'date', required: true, label: 'End Date' },
|
|
266
|
+
role: { type: 'text', label: 'Role' },
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
function withResourceManagementDefaults(masterConfig) {
|
|
271
|
+
const entities = { ...(masterConfig.entities || {}) };
|
|
272
|
+
let changed = false;
|
|
273
|
+
if (!entities.resource_allocation) { entities.resource_allocation = RESOURCE_ALLOCATION_ENTITY_DEFAULT; changed = true; }
|
|
274
|
+
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
275
|
+
}
|
|
276
|
+
|
|
256
277
|
const CONTRACT_LIFECYCLE_WORKFLOW = {
|
|
257
278
|
state_field: 'status',
|
|
258
279
|
stages: [
|
|
@@ -295,7 +316,7 @@ function withContractDefaults(masterConfig) {
|
|
|
295
316
|
export class ConfigGeneratorEngine {
|
|
296
317
|
constructor(masterConfig) {
|
|
297
318
|
if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
|
|
298
|
-
this.masterConfig = deepFreeze(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))))));
|
|
319
|
+
this.masterConfig = deepFreeze(withResourceManagementDefaults(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)))))))));
|
|
299
320
|
this.specCache = new LRUCache(100);
|
|
300
321
|
this.debugMode = false;
|
|
301
322
|
this._plugins = new Map();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
// AES-256-GCM: authenticated encryption, so a tampered ciphertext fails
|
|
4
|
+
// decryption loudly instead of silently returning corrupted plaintext. A
|
|
5
|
+
// fresh random IV per value (never reused) is required for GCM's security
|
|
6
|
+
// guarantee -- reusing an IV with the same key breaks confidentiality.
|
|
7
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
8
|
+
const IV_LENGTH = 12;
|
|
9
|
+
const ENCRYPTED_PREFIX = 'enc:v1:';
|
|
10
|
+
|
|
11
|
+
let _key = null;
|
|
12
|
+
function getKey() {
|
|
13
|
+
if (_key) return _key;
|
|
14
|
+
const raw = process.env.FIELD_ENCRYPTION_KEY;
|
|
15
|
+
if (!raw) {
|
|
16
|
+
// Fail loud, never silently store plaintext under an encrypted:true field
|
|
17
|
+
// just because the operator forgot to configure a key.
|
|
18
|
+
throw new Error('FIELD_ENCRYPTION_KEY environment variable is not set; cannot encrypt/decrypt a field marked encrypted:true');
|
|
19
|
+
}
|
|
20
|
+
// Accept either a 64-char hex string or any string, hashed to a stable
|
|
21
|
+
// 32-byte key -- never echoed, never logged, this function's return value
|
|
22
|
+
// is the only place the raw key material appears in memory.
|
|
23
|
+
const key = /^[0-9a-fA-F]{64}$/.test(raw) ? Buffer.from(raw, 'hex') : crypto.createHash('sha256').update(raw).digest();
|
|
24
|
+
_key = key;
|
|
25
|
+
return _key;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function encryptValue(plaintext) {
|
|
29
|
+
const key = getKey();
|
|
30
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
31
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
32
|
+
const serialized = typeof plaintext === 'string' ? plaintext : JSON.stringify(plaintext);
|
|
33
|
+
const ciphertext = Buffer.concat([cipher.update(serialized, 'utf8'), cipher.final()]);
|
|
34
|
+
const authTag = cipher.getAuthTag();
|
|
35
|
+
return ENCRYPTED_PREFIX + Buffer.concat([iv, authTag, ciphertext]).toString('base64');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isEncrypted(value) {
|
|
39
|
+
return typeof value === 'string' && value.startsWith(ENCRYPTED_PREFIX);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function decryptValue(stored) {
|
|
43
|
+
if (!isEncrypted(stored)) return stored;
|
|
44
|
+
const key = getKey();
|
|
45
|
+
const raw = Buffer.from(stored.slice(ENCRYPTED_PREFIX.length), 'base64');
|
|
46
|
+
const iv = raw.subarray(0, IV_LENGTH);
|
|
47
|
+
const authTag = raw.subarray(IV_LENGTH, IV_LENGTH + 16);
|
|
48
|
+
const ciphertext = raw.subarray(IV_LENGTH + 16);
|
|
49
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
50
|
+
decipher.setAuthTag(authTag);
|
|
51
|
+
// setAuthTag + final() throws on any tampering (wrong key, flipped bytes,
|
|
52
|
+
// truncated ciphertext) -- this is the loud-failure guarantee; there is no
|
|
53
|
+
// catch here, a decrypt failure must propagate to the caller as an error.
|
|
54
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(decrypted);
|
|
57
|
+
} catch {
|
|
58
|
+
return decrypted;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Field-def-driven encrypt of every field marked encrypted:true. Fields not
|
|
63
|
+
// present in `record` or not marked are passed through unchanged.
|
|
64
|
+
export function encryptFields(record, specFields) {
|
|
65
|
+
if (!specFields) return record;
|
|
66
|
+
const result = { ...record };
|
|
67
|
+
for (const [key, fieldDef] of Object.entries(specFields)) {
|
|
68
|
+
if (!fieldDef.encrypted) continue;
|
|
69
|
+
if (result[key] === undefined || result[key] === null || result[key] === '') continue;
|
|
70
|
+
if (isEncrypted(result[key])) continue; // already encrypted, don't double-wrap
|
|
71
|
+
result[key] = encryptValue(result[key]);
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function decryptFields(record, specFields) {
|
|
77
|
+
if (!record || !specFields) return record;
|
|
78
|
+
const result = { ...record };
|
|
79
|
+
for (const [key, fieldDef] of Object.entries(specFields)) {
|
|
80
|
+
if (!fieldDef.encrypted) continue;
|
|
81
|
+
if (result[key] === undefined || result[key] === null) continue;
|
|
82
|
+
result[key] = decryptValue(result[key]);
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
@@ -157,17 +157,30 @@ function resolveEnumOptions(fieldDef, entityName) {
|
|
|
157
157
|
// UI-side, but server-side is the authoritative check per this session's SEC
|
|
158
158
|
// pattern. Privileged roles (partner/manager) may log time for anyone, e.g.
|
|
159
159
|
// entering a team member's hours on their behalf.
|
|
160
|
-
|
|
161
|
-
|
|
160
|
+
// Shared self-only check: a non-privileged actingUser may only write a
|
|
161
|
+
// record whose user_id matches their own id; partner/admin/manager may write
|
|
162
|
+
// for anyone. One function so every self-only entity enforces identically
|
|
163
|
+
// rather than each growing its own slightly-different copy over time.
|
|
164
|
+
function checkSelfOnlyOwnership(data, options, actionDescription) {
|
|
162
165
|
const actingUser = options?.actingUser;
|
|
163
166
|
if (!actingUser) return null;
|
|
164
167
|
if (['partner', 'admin', 'manager'].includes(actingUser.role)) return null;
|
|
165
168
|
if (data.user_id !== undefined && data.user_id !== actingUser.id) {
|
|
166
|
-
return `Cannot
|
|
169
|
+
return `Cannot ${actionDescription} for another user`;
|
|
167
170
|
}
|
|
168
171
|
return null;
|
|
169
172
|
}
|
|
170
173
|
|
|
174
|
+
function checkTimeEntryOwnership(entityName, data, options) {
|
|
175
|
+
if (entityName !== 'time_entry') return null;
|
|
176
|
+
return checkSelfOnlyOwnership(data, options, 'log time');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function checkResourceAllocationOwnership(entityName, data, options) {
|
|
180
|
+
if (entityName !== 'resource_allocation') return null;
|
|
181
|
+
return checkSelfOnlyOwnership(data, options, 'create a resource allocation');
|
|
182
|
+
}
|
|
183
|
+
|
|
171
184
|
// Stock movements are immutable history (checked at creation only, never on
|
|
172
185
|
// edit -- there is no update path for them). An outbound movement (negative
|
|
173
186
|
// quantity) that would drive the running balance below zero is invalid: the
|
|
@@ -205,6 +218,40 @@ function checkContractDateOrder(entityName, data, existingRecord) {
|
|
|
205
218
|
return null;
|
|
206
219
|
}
|
|
207
220
|
|
|
221
|
+
const WEEKLY_CAPACITY_HOURS = 40;
|
|
222
|
+
|
|
223
|
+
// Two date ranges overlap unless one entirely precedes the other -- the
|
|
224
|
+
// standard interval-intersection test, not a same-day/exact-match check.
|
|
225
|
+
function rangesOverlap(startA, endA, startB, endB) {
|
|
226
|
+
return Number(startA) <= Number(endB) && Number(startB) <= Number(endA);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// A user's total weekly allocation across every project they're assigned to,
|
|
230
|
+
// for any date range that overlaps the new/changed allocation, must not
|
|
231
|
+
// exceed a configurable weekly capacity. Checked against EVERY OTHER
|
|
232
|
+
// existing allocation for that user (excluding the record being updated, if
|
|
233
|
+
// any) plus the new one -- never trusting a client-supplied total.
|
|
234
|
+
async function checkResourceCapacity(entityName, data, existingRecord) {
|
|
235
|
+
if (entityName !== 'resource_allocation') return null;
|
|
236
|
+
const userId = data.user_id !== undefined ? data.user_id : existingRecord?.user_id;
|
|
237
|
+
const startDate = data.start_date !== undefined ? data.start_date : existingRecord?.start_date;
|
|
238
|
+
const endDate = data.end_date !== undefined ? data.end_date : existingRecord?.end_date;
|
|
239
|
+
const hours = Number(data.allocated_hours_per_week !== undefined ? data.allocated_hours_per_week : existingRecord?.allocated_hours_per_week);
|
|
240
|
+
if (!userId || startDate == null || endDate == null || !Number.isFinite(hours)) return null;
|
|
241
|
+
|
|
242
|
+
const { list } = await import('@/lib/busybase/store');
|
|
243
|
+
const existingAllocations = await list('resource_allocation', { user_id: userId });
|
|
244
|
+
const overlapping = existingAllocations.filter(a =>
|
|
245
|
+
a.id !== existingRecord?.id && rangesOverlap(startDate, endDate, a.start_date, a.end_date)
|
|
246
|
+
);
|
|
247
|
+
const overlappingTotal = overlapping.reduce((sum, a) => sum + (Number(a.allocated_hours_per_week) || 0), 0);
|
|
248
|
+
const resultingTotal = overlappingTotal + hours;
|
|
249
|
+
if (resultingTotal > WEEKLY_CAPACITY_HOURS) {
|
|
250
|
+
return `Over-allocated: this would bring the user's weekly total to ${resultingTotal}h against overlapping allocations, exceeding the ${WEEKLY_CAPACITY_HOURS}h capacity`;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
208
255
|
export async function validateEntity(entityName, data, existingRecord = null, options = {}) {
|
|
209
256
|
const spec = getSpec(entityName);
|
|
210
257
|
const errors = {};
|
|
@@ -232,12 +279,15 @@ export async function validateEntity(entityName, data, existingRecord = null, op
|
|
|
232
279
|
const stockErr = await checkStockBalance(entityName, data);
|
|
233
280
|
if (stockErr) errors.quantity = stockErr;
|
|
234
281
|
|
|
235
|
-
const ownershipErr = checkTimeEntryOwnership(entityName, data, options);
|
|
282
|
+
const ownershipErr = checkTimeEntryOwnership(entityName, data, options) || checkResourceAllocationOwnership(entityName, data, options);
|
|
236
283
|
if (ownershipErr) errors.user_id = ownershipErr;
|
|
237
284
|
|
|
238
285
|
const dateOrderErr = checkContractDateOrder(entityName, data, existingRecord);
|
|
239
286
|
if (dateOrderErr) errors.end_date = dateOrderErr;
|
|
240
287
|
|
|
288
|
+
const capacityErr = await checkResourceCapacity(entityName, data, existingRecord);
|
|
289
|
+
if (capacityErr) errors.allocated_hours_per_week = capacityErr;
|
|
290
|
+
|
|
241
291
|
return errors;
|
|
242
292
|
}
|
|
243
293
|
|
|
@@ -316,6 +366,9 @@ export async function validateUpdate(entityName, changes, existingRecord) {
|
|
|
316
366
|
const dateOrderErr = checkContractDateOrder(entityName, changes, existingRecord);
|
|
317
367
|
if (dateOrderErr) errors.end_date = dateOrderErr;
|
|
318
368
|
|
|
369
|
+
const capacityErr = await checkResourceCapacity(entityName, changes, existingRecord);
|
|
370
|
+
if (capacityErr) errors.allocated_hours_per_week = capacityErr;
|
|
371
|
+
|
|
319
372
|
return errors;
|
|
320
373
|
}
|
|
321
374
|
|
|
@@ -160,9 +160,23 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
|
|
|
160
160
|
})()
|
|
161
161
|
: ''
|
|
162
162
|
|
|
163
|
+
// allocated_hours_per_week/weekly_capacity_hours are computed (page-handler.js
|
|
164
|
+
// sums resource_allocation rows for this user), same not-a-spec-field
|
|
165
|
+
// treatment as current_stock/total_hours above.
|
|
166
|
+
const utilizationRow = entityName === 'user' && typeof item.allocated_hours_per_week === 'number'
|
|
167
|
+
? (() => {
|
|
168
|
+
const over = item.allocated_hours_per_week > item.weekly_capacity_hours
|
|
169
|
+
const pillCls = over ? 'pill-danger' : 'pill-success'
|
|
170
|
+
return `<div class="detail-row">
|
|
171
|
+
<span class="detail-row-label">Resource Utilization</span>
|
|
172
|
+
<span class="detail-row-value"><span class="pill ${pillCls}">${esc(String(item.allocated_hours_per_week))}h / ${esc(String(item.weekly_capacity_hours))}h${over ? ' (Over-allocated)' : ''}</span></span>
|
|
173
|
+
</div>`
|
|
174
|
+
})()
|
|
175
|
+
: ''
|
|
176
|
+
|
|
163
177
|
const visibleFields = Object.entries(fields).filter(([k]) => k !== 'id' && !HIDDEN_FIELDS.has(k) && item[k] !== undefined)
|
|
164
178
|
|
|
165
|
-
const fieldRows = stockRow + timeTrackingRows + expiryRow + visibleFields.map(([k, f]) =>
|
|
179
|
+
const fieldRows = stockRow + timeTrackingRows + expiryRow + utilizationRow + visibleFields.map(([k, f]) =>
|
|
166
180
|
`<div class="detail-row">
|
|
167
181
|
<span class="detail-row-label">${esc(f.label || k)}</span>
|
|
168
182
|
<span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
|
package/src/ui/page-handler.js
CHANGED
|
@@ -158,6 +158,19 @@ async function handleGenericEntityView(user, entityName, id, req) {
|
|
|
158
158
|
const { daysUntilExpiry } = await import('@/lib/contract-expiry.js');
|
|
159
159
|
resolvedItem = { ...resolvedItem, days_until_expiry: daysUntilExpiry(resolvedItem.end_date) };
|
|
160
160
|
}
|
|
161
|
+
if (entityName === 'user') {
|
|
162
|
+
// Resource utilization reuses the same "sum grouped by owner" rollup
|
|
163
|
+
// shape as task/project total_hours above, just grouped by allocation
|
|
164
|
+
// rather than time entry -- no new aggregation logic, only overlapping
|
|
165
|
+
// (currently-active) allocations count toward the displayed total.
|
|
166
|
+
try {
|
|
167
|
+
const allocations = await list('resource_allocation', { user_id: id });
|
|
168
|
+
const nowTs = Math.floor(Date.now() / 1000);
|
|
169
|
+
const activeAllocations = allocations.filter(a => Number(a.start_date) <= nowTs && nowTs <= Number(a.end_date));
|
|
170
|
+
const allocatedHoursPerWeek = activeAllocations.reduce((sum, a) => sum + (Number(a.allocated_hours_per_week) || 0), 0);
|
|
171
|
+
resolvedItem = { ...resolvedItem, allocated_hours_per_week: allocatedHoursPerWeek, weekly_capacity_hours: 40 };
|
|
172
|
+
} catch { resolvedItem = { ...resolvedItem, allocated_hours_per_week: 0, weekly_capacity_hours: 40 }; }
|
|
173
|
+
}
|
|
161
174
|
// get(...,{user}) above already enforced row/org access for this exact
|
|
162
175
|
// record (a denied/absent record returns null before this point), so
|
|
163
176
|
// fetching its audit trail here is scoped by construction -- there is no
|