feat: auto-sync, version history, merge, org policy, tamper guard
New modules: - version-history.js: IndexedDB snapshot storage with pruning/rollback - merge.js: three-way field-level merge for sync conflict resolution - org-policy.js: team/org policy enforcement, compliance checking, invite codes, required mappings that can't be disabled - tamper-guard.js: admin password to protect disable/clear/export actions, org policy can prevent disabling Auto background sync: - Gist/URL sync via chrome.alarms (MV3-safe, survives SW restarts) - Configurable interval (5/15/30 min), push on local changes - performAutoSync() orchestrates pull-then-conditional-push Multi-device dashboard: - Device auto-registration with UUID + browser/platform detection - Device list embedded in sync data for cross-device visibility - getDeviceInfo(), setDeviceName(), getDevices(), removeDevice() Manifest changes: - Added "alarms" permission for background polling Service worker: - Alarm listeners for auto-sync and org policy polling (hourly) - Tamper guard message handlers - Alarms set up on install and startup WIP: Options UI integration pending for all new features. https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn
This commit is contained in:
@@ -0,0 +1,704 @@
|
||||
/**
|
||||
* Silent Send - Three-Way Field-Level Merge
|
||||
*
|
||||
* Handles conflict resolution when syncing data between devices.
|
||||
* Instead of "newest wins", compares an ancestor (last synced state)
|
||||
* against both local and remote changes, auto-merges non-conflicting
|
||||
* fields, and surfaces true conflicts for the user to resolve.
|
||||
*
|
||||
* Data structures merged:
|
||||
* - identity: { profiles: [{ id, name, active, emails, names, ... }] }
|
||||
* - mappings: [{ id, real, substitute, category, ... }]
|
||||
* - settings: { enabled, showHighlights, customDomains, ... }
|
||||
*/
|
||||
|
||||
/**
|
||||
* Deep-equal comparison for two values (objects, arrays, primitives).
|
||||
* Good enough for JSON-serializable sync data.
|
||||
* @param {*} a
|
||||
* @param {*} b
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function deepEqual(a, b) {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return a === b;
|
||||
if (typeof a !== typeof b) return false;
|
||||
|
||||
if (Array.isArray(a)) {
|
||||
if (!Array.isArray(b) || a.length !== b.length) return false;
|
||||
return a.every((v, i) => deepEqual(v, b[i]));
|
||||
}
|
||||
|
||||
if (typeof a === 'object') {
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
return keysA.every((k) => deepEqual(a[k], b[k]));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone a JSON-serializable value.
|
||||
* @param {*} v
|
||||
* @returns {*}
|
||||
*/
|
||||
function clone(v) {
|
||||
if (v == null || typeof v !== 'object') return v;
|
||||
return JSON.parse(JSON.stringify(v));
|
||||
}
|
||||
|
||||
const SilentSendMerge = {
|
||||
/**
|
||||
* Three-way merge of all sync data.
|
||||
*
|
||||
* @param {{ identity: object, mappings: Array, settings: object }} ancestor - last synced state
|
||||
* @param {{ identity: object, mappings: Array, settings: object }} local - current local state
|
||||
* @param {{ identity: object, mappings: Array, settings: object }} remote - incoming remote state
|
||||
* @returns {{ merged: { identity: object, mappings: Array, settings: object }, conflicts: Array<{ id: string, path: string, localValue: *, remoteValue: *, ancestorValue: * }> }}
|
||||
*/
|
||||
mergeData(ancestor, local, remote) {
|
||||
const mappingsResult = this._mergeMappings(
|
||||
ancestor?.mappings || [],
|
||||
local?.mappings || [],
|
||||
remote?.mappings || [],
|
||||
);
|
||||
|
||||
const settingsResult = this._mergeSettings(
|
||||
ancestor?.settings || {},
|
||||
local?.settings || {},
|
||||
remote?.settings || {},
|
||||
);
|
||||
|
||||
const identityResult = this._mergeIdentity(
|
||||
ancestor?.identity || {},
|
||||
local?.identity || {},
|
||||
remote?.identity || {},
|
||||
);
|
||||
|
||||
return {
|
||||
merged: {
|
||||
identity: identityResult.merged,
|
||||
mappings: mappingsResult.merged,
|
||||
settings: settingsResult.merged,
|
||||
},
|
||||
conflicts: [
|
||||
...mappingsResult.conflicts,
|
||||
...settingsResult.conflicts,
|
||||
...identityResult.conflicts,
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge mappings arrays by `id` field.
|
||||
*
|
||||
* Rules:
|
||||
* - New on one side only -> auto-add
|
||||
* - Deleted on one side, unchanged on other -> auto-delete
|
||||
* - Deleted on one side, changed on other -> conflict
|
||||
* - Changed on both sides -> conflict (unless changes are identical)
|
||||
* - Changed on one side only -> accept the change
|
||||
*
|
||||
* @param {Array} ancestor
|
||||
* @param {Array} local
|
||||
* @param {Array} remote
|
||||
* @returns {{ merged: Array, conflicts: Array }}
|
||||
*/
|
||||
_mergeMappings(ancestor, local, remote) {
|
||||
const ancestorMap = new Map(ancestor.map((m) => [m.id, m]));
|
||||
const localMap = new Map(local.map((m) => [m.id, m]));
|
||||
const remoteMap = new Map(remote.map((m) => [m.id, m]));
|
||||
|
||||
const allIds = new Set([
|
||||
...ancestorMap.keys(),
|
||||
...localMap.keys(),
|
||||
...remoteMap.keys(),
|
||||
]);
|
||||
|
||||
const merged = [];
|
||||
const conflicts = [];
|
||||
|
||||
for (const id of allIds) {
|
||||
const a = ancestorMap.get(id);
|
||||
const l = localMap.get(id);
|
||||
const r = remoteMap.get(id);
|
||||
|
||||
const inA = ancestorMap.has(id);
|
||||
const inL = localMap.has(id);
|
||||
const inR = remoteMap.has(id);
|
||||
|
||||
// New on local only (not in ancestor or remote)
|
||||
if (!inA && inL && !inR) {
|
||||
merged.push(clone(l));
|
||||
continue;
|
||||
}
|
||||
|
||||
// New on remote only
|
||||
if (!inA && !inL && inR) {
|
||||
merged.push(clone(r));
|
||||
continue;
|
||||
}
|
||||
|
||||
// New on both sides (same id independently — unlikely with UUIDs but handle it)
|
||||
if (!inA && inL && inR) {
|
||||
if (deepEqual(l, r)) {
|
||||
merged.push(clone(l));
|
||||
} else {
|
||||
// Both added with same id but different content — conflict
|
||||
merged.push(clone(l));
|
||||
conflicts.push({
|
||||
id,
|
||||
path: `mappings[${id}]`,
|
||||
localValue: clone(l),
|
||||
remoteValue: clone(r),
|
||||
ancestorValue: null,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Existed in ancestor
|
||||
if (inA) {
|
||||
const localChanged = !deepEqual(a, l);
|
||||
const remoteChanged = !deepEqual(a, r);
|
||||
const localDeleted = !inL;
|
||||
const remoteDeleted = !inR;
|
||||
|
||||
// Deleted on both sides
|
||||
if (localDeleted && remoteDeleted) {
|
||||
continue; // gone
|
||||
}
|
||||
|
||||
// Deleted locally, unchanged remotely -> delete
|
||||
if (localDeleted && !remoteChanged) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deleted remotely, unchanged locally -> delete
|
||||
if (remoteDeleted && !localChanged) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deleted on one side but changed on other -> conflict
|
||||
if (localDeleted && remoteChanged) {
|
||||
conflicts.push({
|
||||
id,
|
||||
path: `mappings[${id}]`,
|
||||
localValue: null,
|
||||
remoteValue: clone(r),
|
||||
ancestorValue: clone(a),
|
||||
});
|
||||
// Keep the remote version in merged for now (user must resolve)
|
||||
merged.push(clone(r));
|
||||
continue;
|
||||
}
|
||||
if (remoteDeleted && localChanged) {
|
||||
conflicts.push({
|
||||
id,
|
||||
path: `mappings[${id}]`,
|
||||
localValue: clone(l),
|
||||
remoteValue: null,
|
||||
ancestorValue: clone(a),
|
||||
});
|
||||
merged.push(clone(l));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Both present
|
||||
if (!localChanged && !remoteChanged) {
|
||||
merged.push(clone(a)); // unchanged
|
||||
} else if (localChanged && !remoteChanged) {
|
||||
merged.push(clone(l));
|
||||
} else if (!localChanged && remoteChanged) {
|
||||
merged.push(clone(r));
|
||||
} else {
|
||||
// Both changed
|
||||
if (deepEqual(l, r)) {
|
||||
merged.push(clone(l)); // same change on both sides
|
||||
} else {
|
||||
merged.push(clone(l)); // default to local, flag conflict
|
||||
conflicts.push({
|
||||
id,
|
||||
path: `mappings[${id}]`,
|
||||
localValue: clone(l),
|
||||
remoteValue: clone(r),
|
||||
ancestorValue: clone(a),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { merged, conflicts };
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge settings objects by key.
|
||||
*
|
||||
* `customDomains` is treated specially: union of both sides (deduplicated).
|
||||
* All other keys use standard three-way field comparison.
|
||||
*
|
||||
* @param {object} ancestor
|
||||
* @param {object} local
|
||||
* @param {object} remote
|
||||
* @returns {{ merged: object, conflicts: Array }}
|
||||
*/
|
||||
_mergeSettings(ancestor, local, remote) {
|
||||
const merged = {};
|
||||
const conflicts = [];
|
||||
|
||||
const allKeys = new Set([
|
||||
...Object.keys(ancestor),
|
||||
...Object.keys(local),
|
||||
...Object.keys(remote),
|
||||
]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
const a = ancestor[key];
|
||||
const l = local[key];
|
||||
const r = remote[key];
|
||||
|
||||
// Special handling for customDomains — union both sides
|
||||
if (key === 'customDomains') {
|
||||
const localDomains = Array.isArray(l) ? l : [];
|
||||
const remoteDomains = Array.isArray(r) ? r : [];
|
||||
merged[key] = [...new Set([...localDomains, ...remoteDomains])];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Special handling for categories — union both sides
|
||||
if (key === 'categories') {
|
||||
const localCats = Array.isArray(l) ? l : [];
|
||||
const remoteCats = Array.isArray(r) ? r : [];
|
||||
merged[key] = [...new Set([...localCats, ...remoteCats])];
|
||||
continue;
|
||||
}
|
||||
|
||||
const localChanged = !deepEqual(a, l);
|
||||
const remoteChanged = !deepEqual(a, r);
|
||||
|
||||
if (!localChanged && !remoteChanged) {
|
||||
merged[key] = clone(a); // unchanged
|
||||
} else if (localChanged && !remoteChanged) {
|
||||
merged[key] = clone(l);
|
||||
} else if (!localChanged && remoteChanged) {
|
||||
merged[key] = clone(r);
|
||||
} else {
|
||||
// Both changed
|
||||
if (deepEqual(l, r)) {
|
||||
merged[key] = clone(l); // same change
|
||||
} else {
|
||||
merged[key] = clone(l); // default to local, flag conflict
|
||||
conflicts.push({
|
||||
id: key,
|
||||
path: `settings.${key}`,
|
||||
localValue: clone(l),
|
||||
remoteValue: clone(r),
|
||||
ancestorValue: clone(a),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { merged, conflicts };
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge identity objects.
|
||||
*
|
||||
* Profiles are matched by `id`. Within each profile, top-level scalar
|
||||
* fields (name, active, catchAllEmail, etc.) use three-way comparison.
|
||||
* Array fields (emails, names, usernames, hostnames, phones, emailDomains)
|
||||
* are compared by index position for simplicity.
|
||||
*
|
||||
* @param {object} ancestor
|
||||
* @param {object} local
|
||||
* @param {object} remote
|
||||
* @returns {{ merged: object, conflicts: Array }}
|
||||
*/
|
||||
_mergeIdentity(ancestor, local, remote) {
|
||||
const ancestorProfiles = ancestor?.profiles || [];
|
||||
const localProfiles = local?.profiles || [];
|
||||
const remoteProfiles = remote?.profiles || [];
|
||||
|
||||
const ancestorMap = new Map(ancestorProfiles.map((p) => [p.id, p]));
|
||||
const localMap = new Map(localProfiles.map((p) => [p.id, p]));
|
||||
const remoteMap = new Map(remoteProfiles.map((p) => [p.id, p]));
|
||||
|
||||
const allIds = new Set([
|
||||
...ancestorMap.keys(),
|
||||
...localMap.keys(),
|
||||
...remoteMap.keys(),
|
||||
]);
|
||||
|
||||
const mergedProfiles = [];
|
||||
const conflicts = [];
|
||||
|
||||
for (const id of allIds) {
|
||||
const a = ancestorMap.get(id);
|
||||
const l = localMap.get(id);
|
||||
const r = remoteMap.get(id);
|
||||
|
||||
const inA = ancestorMap.has(id);
|
||||
const inL = localMap.has(id);
|
||||
const inR = remoteMap.has(id);
|
||||
|
||||
// New on one side only
|
||||
if (!inA && inL && !inR) {
|
||||
mergedProfiles.push(clone(l));
|
||||
continue;
|
||||
}
|
||||
if (!inA && !inL && inR) {
|
||||
mergedProfiles.push(clone(r));
|
||||
continue;
|
||||
}
|
||||
if (!inA && inL && inR) {
|
||||
if (deepEqual(l, r)) {
|
||||
mergedProfiles.push(clone(l));
|
||||
} else {
|
||||
mergedProfiles.push(clone(l));
|
||||
conflicts.push({
|
||||
id,
|
||||
path: `identity.profiles[${id}]`,
|
||||
localValue: clone(l),
|
||||
remoteValue: clone(r),
|
||||
ancestorValue: null,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inA) continue;
|
||||
|
||||
const localDeleted = !inL;
|
||||
const remoteDeleted = !inR;
|
||||
|
||||
// Both deleted
|
||||
if (localDeleted && remoteDeleted) continue;
|
||||
|
||||
// Deleted on one side, check if other side changed
|
||||
if (localDeleted) {
|
||||
if (deepEqual(a, r)) {
|
||||
continue; // deleted locally, unchanged remotely -> delete
|
||||
}
|
||||
conflicts.push({
|
||||
id,
|
||||
path: `identity.profiles[${id}]`,
|
||||
localValue: null,
|
||||
remoteValue: clone(r),
|
||||
ancestorValue: clone(a),
|
||||
});
|
||||
mergedProfiles.push(clone(r));
|
||||
continue;
|
||||
}
|
||||
if (remoteDeleted) {
|
||||
if (deepEqual(a, l)) {
|
||||
continue; // deleted remotely, unchanged locally -> delete
|
||||
}
|
||||
conflicts.push({
|
||||
id,
|
||||
path: `identity.profiles[${id}]`,
|
||||
localValue: clone(l),
|
||||
remoteValue: null,
|
||||
ancestorValue: clone(a),
|
||||
});
|
||||
mergedProfiles.push(clone(l));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Both present — merge field by field within the profile
|
||||
const mergedProfile = this._mergeProfile(id, a, l, r, conflicts);
|
||||
mergedProfiles.push(mergedProfile);
|
||||
}
|
||||
|
||||
return {
|
||||
merged: { profiles: mergedProfiles },
|
||||
conflicts,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge a single identity profile field by field.
|
||||
*
|
||||
* Scalar fields: standard three-way comparison.
|
||||
* Array fields (emails, names, etc.): compared by index position.
|
||||
* The `enabled` sub-object: merged key by key.
|
||||
*
|
||||
* @param {string} profileId
|
||||
* @param {object} ancestor
|
||||
* @param {object} local
|
||||
* @param {object} remote
|
||||
* @param {Array} conflicts - mutated; conflicts are pushed here
|
||||
* @returns {object} merged profile
|
||||
*/
|
||||
_mergeProfile(profileId, ancestor, local, remote, conflicts) {
|
||||
const merged = { id: profileId };
|
||||
const arrayFields = new Set(['emails', 'names', 'usernames', 'hostnames', 'phones', 'emailDomains']);
|
||||
const allKeys = new Set([
|
||||
...Object.keys(ancestor),
|
||||
...Object.keys(local),
|
||||
...Object.keys(remote),
|
||||
]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (key === 'id') continue;
|
||||
|
||||
const a = ancestor[key];
|
||||
const l = local[key];
|
||||
const r = remote[key];
|
||||
|
||||
// Merge `enabled` sub-object key by key
|
||||
if (key === 'enabled' && typeof l === 'object' && typeof r === 'object') {
|
||||
merged[key] = this._mergeEnabledFlags(profileId, a || {}, l, r, conflicts);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Array fields — compare by index
|
||||
if (arrayFields.has(key)) {
|
||||
merged[key] = this._mergeArray(profileId, key, a || [], l || [], r || [], conflicts);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scalar fields
|
||||
const localChanged = !deepEqual(a, l);
|
||||
const remoteChanged = !deepEqual(a, r);
|
||||
|
||||
if (!localChanged && !remoteChanged) {
|
||||
merged[key] = clone(a);
|
||||
} else if (localChanged && !remoteChanged) {
|
||||
merged[key] = clone(l);
|
||||
} else if (!localChanged && remoteChanged) {
|
||||
merged[key] = clone(r);
|
||||
} else {
|
||||
if (deepEqual(l, r)) {
|
||||
merged[key] = clone(l);
|
||||
} else {
|
||||
merged[key] = clone(l);
|
||||
conflicts.push({
|
||||
id: `${profileId}.${key}`,
|
||||
path: `identity.profiles[${profileId}].${key}`,
|
||||
localValue: clone(l),
|
||||
remoteValue: clone(r),
|
||||
ancestorValue: clone(a),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge the `enabled` flags sub-object within a profile.
|
||||
*
|
||||
* @param {string} profileId
|
||||
* @param {object} ancestor
|
||||
* @param {object} local
|
||||
* @param {object} remote
|
||||
* @param {Array} conflicts
|
||||
* @returns {object}
|
||||
*/
|
||||
_mergeEnabledFlags(profileId, ancestor, local, remote, conflicts) {
|
||||
const merged = {};
|
||||
const allKeys = new Set([
|
||||
...Object.keys(ancestor),
|
||||
...Object.keys(local),
|
||||
...Object.keys(remote),
|
||||
]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
const a = ancestor[key];
|
||||
const l = local[key];
|
||||
const r = remote[key];
|
||||
|
||||
const localChanged = a !== l;
|
||||
const remoteChanged = a !== r;
|
||||
|
||||
if (!localChanged && !remoteChanged) {
|
||||
merged[key] = a;
|
||||
} else if (localChanged && !remoteChanged) {
|
||||
merged[key] = l;
|
||||
} else if (!localChanged && remoteChanged) {
|
||||
merged[key] = r;
|
||||
} else {
|
||||
if (l === r) {
|
||||
merged[key] = l;
|
||||
} else {
|
||||
merged[key] = l;
|
||||
conflicts.push({
|
||||
id: `${profileId}.enabled.${key}`,
|
||||
path: `identity.profiles[${profileId}].enabled.${key}`,
|
||||
localValue: l,
|
||||
remoteValue: r,
|
||||
ancestorValue: a,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge an array field within a profile by index position.
|
||||
*
|
||||
* Entries that exist at the same index in ancestor, local, and remote
|
||||
* are compared field by field. New entries appended on either side are
|
||||
* added. Entries removed from one side but unchanged on other are removed.
|
||||
*
|
||||
* For simplicity, if the array lengths diverge in complex ways, we fall
|
||||
* back to whole-array comparison and flag a conflict if both changed.
|
||||
*
|
||||
* @param {string} profileId
|
||||
* @param {string} fieldName
|
||||
* @param {Array} ancestor
|
||||
* @param {Array} local
|
||||
* @param {Array} remote
|
||||
* @param {Array} conflicts
|
||||
* @returns {Array}
|
||||
*/
|
||||
_mergeArray(profileId, fieldName, ancestor, local, remote, conflicts) {
|
||||
const maxLen = Math.max(ancestor.length, local.length, remote.length);
|
||||
|
||||
// Simple case: neither side changed
|
||||
if (deepEqual(ancestor, local) && deepEqual(ancestor, remote)) {
|
||||
return clone(ancestor);
|
||||
}
|
||||
|
||||
// Only one side changed
|
||||
if (deepEqual(ancestor, local) && !deepEqual(ancestor, remote)) {
|
||||
return clone(remote);
|
||||
}
|
||||
if (!deepEqual(ancestor, local) && deepEqual(ancestor, remote)) {
|
||||
return clone(local);
|
||||
}
|
||||
|
||||
// Both changed — try index-level merge
|
||||
const merged = [];
|
||||
let hasConflict = false;
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const a = i < ancestor.length ? ancestor[i] : undefined;
|
||||
const l = i < local.length ? local[i] : undefined;
|
||||
const r = i < remote.length ? remote[i] : undefined;
|
||||
|
||||
const localChanged = !deepEqual(a, l);
|
||||
const remoteChanged = !deepEqual(a, r);
|
||||
|
||||
if (!localChanged && !remoteChanged) {
|
||||
if (a !== undefined) merged.push(clone(a));
|
||||
} else if (localChanged && !remoteChanged) {
|
||||
if (l !== undefined) merged.push(clone(l));
|
||||
// l undefined means local deleted this index — skip
|
||||
} else if (!localChanged && remoteChanged) {
|
||||
if (r !== undefined) merged.push(clone(r));
|
||||
} else {
|
||||
// Both changed at this index
|
||||
if (deepEqual(l, r)) {
|
||||
if (l !== undefined) merged.push(clone(l));
|
||||
} else {
|
||||
if (l !== undefined) merged.push(clone(l));
|
||||
hasConflict = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasConflict) {
|
||||
conflicts.push({
|
||||
id: `${profileId}.${fieldName}`,
|
||||
path: `identity.profiles[${profileId}].${fieldName}`,
|
||||
localValue: clone(local),
|
||||
remoteValue: clone(remote),
|
||||
ancestorValue: clone(ancestor),
|
||||
});
|
||||
}
|
||||
|
||||
return merged;
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply a user's conflict resolution choice to the merged data.
|
||||
*
|
||||
* Navigates the `path` in the merged object and replaces the value
|
||||
* with either the local or remote version from the conflict record.
|
||||
*
|
||||
* @param {{ identity: object, mappings: Array, settings: object }} merged
|
||||
* @param {{ id: string, path: string, localValue: *, remoteValue: * }} conflict
|
||||
* @param {'local'|'remote'} choice
|
||||
* @returns {{ identity: object, mappings: Array, settings: object }} the mutated merged object
|
||||
*/
|
||||
resolveConflict(merged, conflict, choice) {
|
||||
const value = choice === 'remote' ? conflict.remoteValue : conflict.localValue;
|
||||
const path = conflict.path;
|
||||
|
||||
// Handle mapping conflicts: mappings[<id>]
|
||||
const mappingMatch = path.match(/^mappings\[(.+)]$/);
|
||||
if (mappingMatch) {
|
||||
const id = mappingMatch[1];
|
||||
if (value === null) {
|
||||
// Choice is to delete
|
||||
merged.mappings = merged.mappings.filter((m) => m.id !== id);
|
||||
} else {
|
||||
const idx = merged.mappings.findIndex((m) => m.id === id);
|
||||
if (idx !== -1) {
|
||||
merged.mappings[idx] = clone(value);
|
||||
} else {
|
||||
merged.mappings.push(clone(value));
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Handle settings conflicts: settings.<key>
|
||||
const settingsMatch = path.match(/^settings\.(.+)$/);
|
||||
if (settingsMatch) {
|
||||
const key = settingsMatch[1];
|
||||
merged.settings[key] = clone(value);
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Handle identity profile-level conflicts: identity.profiles[<id>]
|
||||
const profileMatch = path.match(/^identity\.profiles\[(.+)]$/);
|
||||
if (profileMatch) {
|
||||
const id = profileMatch[1];
|
||||
if (value === null) {
|
||||
merged.identity.profiles = merged.identity.profiles.filter((p) => p.id !== id);
|
||||
} else {
|
||||
const idx = merged.identity.profiles.findIndex((p) => p.id === id);
|
||||
if (idx !== -1) {
|
||||
merged.identity.profiles[idx] = clone(value);
|
||||
} else {
|
||||
merged.identity.profiles.push(clone(value));
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Handle profile field conflicts: identity.profiles[<id>].<field>
|
||||
const fieldMatch = path.match(/^identity\.profiles\[(.+?)]\.(.+)$/);
|
||||
if (fieldMatch) {
|
||||
const id = fieldMatch[1];
|
||||
const fieldPath = fieldMatch[2];
|
||||
const profile = merged.identity.profiles.find((p) => p.id === id);
|
||||
if (profile) {
|
||||
// Handle nested paths like "enabled.emails"
|
||||
const parts = fieldPath.split('.');
|
||||
let target = profile;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
target = target[parts[i]];
|
||||
if (!target) break;
|
||||
}
|
||||
if (target) {
|
||||
target[parts[parts.length - 1]] = clone(value);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
return merged;
|
||||
},
|
||||
};
|
||||
|
||||
export default SilentSendMerge;
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Silent Send - Organization Policy Manager
|
||||
*
|
||||
* Enables team/org administrators to enforce substitution policies
|
||||
* across all team members. Org-required mappings merge with personal
|
||||
* mappings and cannot be disabled by the user.
|
||||
*
|
||||
* Policy distribution:
|
||||
* - Admin hosts a JSON policy file at a URL (static host, S3, etc.)
|
||||
* - Team members join by entering the policy URL or an invite code
|
||||
* - Extension polls the policy URL periodically (default: hourly)
|
||||
* - Policy updates are applied automatically
|
||||
*
|
||||
* Privacy: the org admin can check compliance (are required fields
|
||||
* configured?) but CANNOT see individual PPI values.
|
||||
*/
|
||||
|
||||
import api from './browser-polyfill.js';
|
||||
|
||||
const ORG_CONFIG_KEY = 'ss_org_config';
|
||||
const ORG_POLICY_KEY = 'ss_org_policy';
|
||||
|
||||
const OrgPolicy = {
|
||||
// ----------------------------------------------------------------
|
||||
// Join / Leave
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Join an organization by policy URL or invite code.
|
||||
* Fetches the policy, validates it, and stores locally.
|
||||
*
|
||||
* @param {{ policyUrl?: string, inviteCode?: string }}
|
||||
* @returns {{ success: boolean, orgName?: string, reason?: string }}
|
||||
*/
|
||||
async joinOrg({ policyUrl, inviteCode }) {
|
||||
if (!policyUrl && !inviteCode) {
|
||||
return { success: false, reason: 'Provide a policy URL or invite code.' };
|
||||
}
|
||||
|
||||
// If invite code provided, it encodes the policy URL
|
||||
// Format: base64(JSON({ url: '...', orgId: '...' }))
|
||||
if (inviteCode && !policyUrl) {
|
||||
try {
|
||||
const decoded = JSON.parse(atob(inviteCode.trim()));
|
||||
policyUrl = decoded.url;
|
||||
} catch {
|
||||
return { success: false, reason: 'Invalid invite code.' };
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch and validate the policy
|
||||
try {
|
||||
const resp = await fetch(policyUrl);
|
||||
if (!resp.ok) {
|
||||
return { success: false, reason: `Failed to fetch policy: HTTP ${resp.status}` };
|
||||
}
|
||||
|
||||
const policy = await resp.json();
|
||||
|
||||
if (!policy.orgId || !policy.orgName) {
|
||||
return { success: false, reason: 'Invalid policy — missing orgId or orgName.' };
|
||||
}
|
||||
|
||||
// Validate invite code if the policy requires one
|
||||
if (policy.inviteCode && inviteCode) {
|
||||
try {
|
||||
const decoded = JSON.parse(atob(inviteCode.trim()));
|
||||
if (decoded.orgId !== policy.orgId) {
|
||||
return { success: false, reason: 'Invite code does not match this organization.' };
|
||||
}
|
||||
} catch { /* non-fatal — URL join doesn't need code */ }
|
||||
}
|
||||
|
||||
// Store org config
|
||||
await api.storage.local.set({
|
||||
[ORG_CONFIG_KEY]: {
|
||||
policyUrl,
|
||||
orgId: policy.orgId,
|
||||
orgName: policy.orgName,
|
||||
joinedAt: Date.now(),
|
||||
lastFetch: Date.now(),
|
||||
},
|
||||
[ORG_POLICY_KEY]: policy,
|
||||
});
|
||||
|
||||
return { success: true, orgName: policy.orgName };
|
||||
} catch (e) {
|
||||
return { success: false, reason: 'Failed to fetch policy: ' + e.message };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Leave the organization. Removes org config and policy.
|
||||
* May require admin password if tamper protection is enabled.
|
||||
*/
|
||||
async leaveOrg() {
|
||||
await api.storage.local.remove([ORG_CONFIG_KEY, ORG_POLICY_KEY]);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user is in an organization.
|
||||
*/
|
||||
async isInOrg() {
|
||||
const result = await api.storage.local.get(ORG_CONFIG_KEY);
|
||||
return !!result[ORG_CONFIG_KEY]?.orgId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the current org config (non-sensitive metadata).
|
||||
*/
|
||||
async getOrgConfig() {
|
||||
const result = await api.storage.local.get(ORG_CONFIG_KEY);
|
||||
return result[ORG_CONFIG_KEY] || null;
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Policy fetch and update
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch the latest policy from the org's URL.
|
||||
* Only applies if the version is newer than what we have.
|
||||
*
|
||||
* @returns {{ updated: boolean, reason?: string }}
|
||||
*/
|
||||
async fetchPolicy() {
|
||||
const config = await this.getOrgConfig();
|
||||
if (!config?.policyUrl) return { updated: false, reason: 'No org configured.' };
|
||||
|
||||
try {
|
||||
const resp = await fetch(config.policyUrl);
|
||||
if (!resp.ok) return { updated: false, reason: `HTTP ${resp.status}` };
|
||||
|
||||
const policy = await resp.json();
|
||||
const current = await this.getPolicy();
|
||||
|
||||
// Only update if version is newer
|
||||
if (current && policy.version <= (current.version || 0)) {
|
||||
// Update lastFetch timestamp
|
||||
config.lastFetch = Date.now();
|
||||
await api.storage.local.set({ [ORG_CONFIG_KEY]: config });
|
||||
return { updated: false };
|
||||
}
|
||||
|
||||
// Store updated policy
|
||||
await api.storage.local.set({
|
||||
[ORG_POLICY_KEY]: policy,
|
||||
[ORG_CONFIG_KEY]: { ...config, lastFetch: Date.now() },
|
||||
});
|
||||
|
||||
return { updated: true, version: policy.version };
|
||||
} catch (e) {
|
||||
return { updated: false, reason: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the cached policy.
|
||||
*/
|
||||
async getPolicy() {
|
||||
const result = await api.storage.local.get(ORG_POLICY_KEY);
|
||||
return result[ORG_POLICY_KEY] || null;
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Policy enforcement — merge org rules with personal data
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Merge org-required mappings with personal mappings.
|
||||
* Org mappings are always included and cannot be disabled.
|
||||
*
|
||||
* @param {Array} personalMappings - user's own mappings
|
||||
* @returns {Array} merged mappings (org + personal)
|
||||
*/
|
||||
async getMergedMappings(personalMappings) {
|
||||
const policy = await this.getPolicy();
|
||||
if (!policy?.requiredMappings?.length) return personalMappings;
|
||||
|
||||
const orgMappings = policy.requiredMappings.map(m => ({
|
||||
id: `org-${policy.orgId}-${m.real}`,
|
||||
real: m.real,
|
||||
substitute: m.substitute,
|
||||
category: m.category || 'org',
|
||||
caseSensitive: m.caseSensitive ?? false,
|
||||
enabled: true, // always enabled — cannot be disabled
|
||||
_orgRequired: true,
|
||||
_orgId: policy.orgId,
|
||||
}));
|
||||
|
||||
// Remove personal mappings that conflict with org mappings (same real value)
|
||||
const orgReals = new Set(orgMappings.map(m => m.real.toLowerCase()));
|
||||
const filtered = personalMappings.filter(
|
||||
m => !orgReals.has(m.real.toLowerCase())
|
||||
);
|
||||
|
||||
return [...orgMappings, ...filtered];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get org-required secret scanner patterns.
|
||||
*
|
||||
* @returns {Array} additional patterns to add to the secret scanner
|
||||
*/
|
||||
async getOrgSecretPatterns() {
|
||||
const policy = await this.getPolicy();
|
||||
if (!policy?.requiredSecretPatterns?.length) return [];
|
||||
|
||||
return policy.requiredSecretPatterns.map(p => ({
|
||||
name: p.name,
|
||||
re: new RegExp(p.regex, 'g'),
|
||||
to: p.redact || '[REDACTED]',
|
||||
_orgRequired: true,
|
||||
}));
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Compliance checking (anonymized)
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if the user's configuration meets org policy requirements.
|
||||
* Returns compliance status WITHOUT revealing actual PPI values.
|
||||
*
|
||||
* @returns {{ compliant: boolean, missing: string[], configured: string[] }}
|
||||
*/
|
||||
async checkCompliance() {
|
||||
const policy = await this.getPolicy();
|
||||
if (!policy) return { compliant: true, missing: [], configured: [] };
|
||||
|
||||
const result = await api.storage.local.get('ss_identity');
|
||||
const identity = result.ss_identity || {};
|
||||
const profiles = identity.profiles || [];
|
||||
const active = profiles.filter(p => p.active);
|
||||
|
||||
const missing = [];
|
||||
const configured = [];
|
||||
|
||||
const rules = policy.sharedIdentityRules || {};
|
||||
|
||||
// Check required categories
|
||||
if (rules.requiredCategories) {
|
||||
for (const cat of rules.requiredCategories) {
|
||||
let found = false;
|
||||
for (const p of active) {
|
||||
switch (cat) {
|
||||
case 'name':
|
||||
if ((p.names || []).some(n => n.real && n.substitute)) found = true;
|
||||
break;
|
||||
case 'email':
|
||||
if ((p.emails || []).some(e => e.real && e.substitute) || p.catchAllEmail) found = true;
|
||||
break;
|
||||
case 'username':
|
||||
if ((p.usernames || []).some(u => u.real && u.substitute)) found = true;
|
||||
break;
|
||||
case 'hostname':
|
||||
if ((p.hostnames || []).some(h => h.real && h.substitute)) found = true;
|
||||
break;
|
||||
case 'phone':
|
||||
if ((p.phones || []).some(ph => ph.real && ph.substitute)) found = true;
|
||||
break;
|
||||
case 'domain':
|
||||
found = true; // org-required mappings handle this
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) {
|
||||
configured.push(cat);
|
||||
} else {
|
||||
missing.push(cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check catch-all email requirement
|
||||
if (rules.requireCatchAllEmail) {
|
||||
const hasCatchAll = active.some(p => !!p.catchAllEmail);
|
||||
if (hasCatchAll) {
|
||||
configured.push('catch-all email');
|
||||
} else {
|
||||
missing.push('catch-all email');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
compliant: missing.length === 0,
|
||||
missing,
|
||||
configured,
|
||||
};
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Invite code generation (for admins)
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate an invite code from a policy URL and org ID.
|
||||
* This is a simple base64 encoding — not a secret.
|
||||
*/
|
||||
generateInviteCode(policyUrl, orgId) {
|
||||
return btoa(JSON.stringify({ url: policyUrl, orgId }));
|
||||
},
|
||||
};
|
||||
|
||||
export default OrgPolicy;
|
||||
+159
@@ -874,6 +874,165 @@ const SilentSendSync = {
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Auto Sync — background polling for Gist/URL
|
||||
//
|
||||
// Config stored in ss_auto_sync_config (encrypted at rest).
|
||||
// Uses chrome.alarms API for MV3-safe periodic polling.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
async getAutoSyncConfig() {
|
||||
const result = await api.storage.local.get('ss_auto_sync_config');
|
||||
return result.ss_auto_sync_config || null;
|
||||
},
|
||||
|
||||
async saveAutoSyncConfig(config) {
|
||||
await api.storage.local.set({ ss_auto_sync_config: config });
|
||||
},
|
||||
|
||||
/**
|
||||
* Perform an auto-sync cycle: pull from remote, push if local changed.
|
||||
* Called by the alarms listener in the service worker.
|
||||
*
|
||||
* @returns {{ pulled: boolean, pushed: boolean, error?: string }}
|
||||
*/
|
||||
async performAutoSync() {
|
||||
const config = await this.getAutoSyncConfig();
|
||||
if (!config?.enabled) return { pulled: false, pushed: false };
|
||||
|
||||
let pulled = false;
|
||||
let pushed = false;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
if (config.method === 'gist' && config.gistToken) {
|
||||
// Pull from Gist
|
||||
const pullResult = await this.pullFromGist(config.gistToken);
|
||||
if (pullResult.success && pullResult.imported) pulled = true;
|
||||
|
||||
// Push if local data changed since last push
|
||||
const local = await this._getAllData();
|
||||
if (!config.lastPush || local.lastModified > config.lastPush) {
|
||||
const pushResult = await this.pushToGist(config.gistToken);
|
||||
if (pushResult.success) {
|
||||
pushed = true;
|
||||
config.lastPush = Date.now();
|
||||
}
|
||||
}
|
||||
} else if (config.method === 'url' && config.url) {
|
||||
// Pull from URL
|
||||
const headers = config.headers || {};
|
||||
const pullResult = await this.pullFromUrl({ url: config.url, headers });
|
||||
if (pullResult.success && pullResult.imported) pulled = true;
|
||||
|
||||
// Push if local data changed since last push
|
||||
const local = await this._getAllData();
|
||||
if (!config.lastPush || local.lastModified > config.lastPush) {
|
||||
const pushResult = await this.pushToUrl({
|
||||
url: config.url,
|
||||
method: config.httpMethod || 'PUT',
|
||||
headers,
|
||||
});
|
||||
if (pushResult.success) {
|
||||
pushed = true;
|
||||
config.lastPush = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update timestamps
|
||||
config.lastPull = Date.now();
|
||||
await this.saveAutoSyncConfig(config);
|
||||
} catch (e) {
|
||||
error = e.message;
|
||||
}
|
||||
|
||||
return { pulled, pushed, error };
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Multi-Device Dashboard
|
||||
//
|
||||
// Each device registers itself with a unique ID + name.
|
||||
// Device list is embedded in the sync data so all devices
|
||||
// see each other.
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get or create device info for this device.
|
||||
*/
|
||||
async getDeviceInfo() {
|
||||
const result = await api.storage.local.get('ss_device_info');
|
||||
if (result.ss_device_info) return result.ss_device_info;
|
||||
|
||||
// Auto-detect device name
|
||||
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
||||
let browser = 'Unknown';
|
||||
if (ua.includes('Firefox')) browser = 'Firefox';
|
||||
else if (ua.includes('Chrome')) browser = 'Chrome';
|
||||
else if (ua.includes('Safari')) browser = 'Safari';
|
||||
else if (ua.includes('Edge')) browser = 'Edge';
|
||||
|
||||
const platform = typeof navigator !== 'undefined'
|
||||
? (navigator.platform || navigator.userAgentData?.platform || 'Unknown')
|
||||
: 'Unknown';
|
||||
|
||||
const info = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `${browser} on ${platform}`,
|
||||
browser,
|
||||
platform,
|
||||
createdAt: Date.now(),
|
||||
lastSync: Date.now(),
|
||||
};
|
||||
|
||||
await api.storage.local.set({ ss_device_info: info });
|
||||
return info;
|
||||
},
|
||||
|
||||
/**
|
||||
* Rename this device.
|
||||
*/
|
||||
async setDeviceName(name) {
|
||||
const info = await this.getDeviceInfo();
|
||||
info.name = name;
|
||||
await api.storage.local.set({ ss_device_info: info });
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the list of all known devices.
|
||||
*/
|
||||
async getDevices() {
|
||||
const result = await api.storage.local.get('ss_devices');
|
||||
return result.ss_devices || {};
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a device from the tracked list.
|
||||
*/
|
||||
async removeDevice(deviceId) {
|
||||
const devices = await this.getDevices();
|
||||
delete devices[deviceId];
|
||||
await api.storage.local.set({ ss_devices: devices });
|
||||
},
|
||||
|
||||
// Override _getAllData to include device info and device list
|
||||
async _getAllDataWithDevices() {
|
||||
const data = await this._getAllData();
|
||||
const deviceInfo = await this.getDeviceInfo();
|
||||
const devices = await this.getDevices();
|
||||
|
||||
// Register/update this device in the list
|
||||
devices[deviceInfo.id] = {
|
||||
...deviceInfo,
|
||||
lastSync: Date.now(),
|
||||
};
|
||||
await api.storage.local.set({ ss_devices: devices });
|
||||
|
||||
data.devices = devices;
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
export default SilentSendSync;
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Silent Send - Tamper Protection
|
||||
*
|
||||
* Prevents unauthorized disabling, data clearing, or org policy
|
||||
* removal by requiring a separate admin password. This is distinct
|
||||
* from the vault encryption password.
|
||||
*
|
||||
* Protected actions:
|
||||
* - Disabling the extension (toggle off)
|
||||
* - Clearing all data (reset)
|
||||
* - Clearing mappings
|
||||
* - Changing or removing org policy URL
|
||||
* - Exporting data (plain, unencrypted)
|
||||
*
|
||||
* Limitations (documented in UI):
|
||||
* - Cannot prevent browser-level extension uninstall
|
||||
* - Cannot prevent clearing browser data via browser settings
|
||||
* - A determined user with dev tools can bypass this
|
||||
* - This is a deterrent for casual tampering, not a security boundary
|
||||
*
|
||||
* Org integration:
|
||||
* - If org policy sets disableTamperProtection: false, the tamper
|
||||
* guard cannot be turned off even with the admin password
|
||||
* - If org policy sets disableTamperProtection: true, the org
|
||||
* admin is opting out of this feature
|
||||
*/
|
||||
|
||||
import api from './browser-polyfill.js';
|
||||
|
||||
const ADMIN_AUTH_KEY = 'ss_admin_auth';
|
||||
const ITERATIONS = 200000; // higher than sync encryption for extra security
|
||||
const SALT_LENGTH = 16;
|
||||
|
||||
const TamperGuard = {
|
||||
/**
|
||||
* Check if tamper protection is enabled.
|
||||
*/
|
||||
async isEnabled() {
|
||||
const result = await api.storage.local.get(ADMIN_AUTH_KEY);
|
||||
return !!result[ADMIN_AUTH_KEY]?.enabled;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the tamper guard config (without the password hash).
|
||||
*/
|
||||
async getConfig() {
|
||||
const result = await api.storage.local.get(ADMIN_AUTH_KEY);
|
||||
const config = result[ADMIN_AUTH_KEY];
|
||||
if (!config) return null;
|
||||
// Don't expose the hash
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
protectedActions: config.protectedActions,
|
||||
orgCanDisable: config.orgCanDisable,
|
||||
createdAt: config.createdAt,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Set up tamper protection with an admin password.
|
||||
*
|
||||
* @param {string} adminPassword - must be different from encryption password
|
||||
* @returns {{ success: boolean, reason?: string }}
|
||||
*/
|
||||
async setup(adminPassword) {
|
||||
if (!adminPassword || adminPassword.length < 4) {
|
||||
return { success: false, reason: 'Admin password must be at least 4 characters.' };
|
||||
}
|
||||
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
||||
const hash = await this._hashPassword(adminPassword, salt);
|
||||
|
||||
const config = {
|
||||
enabled: true,
|
||||
passwordHash: hash,
|
||||
salt: btoa(String.fromCharCode(...salt)),
|
||||
protectedActions: [
|
||||
'disable',
|
||||
'clearData',
|
||||
'clearMappings',
|
||||
'changeOrgPolicy',
|
||||
'exportPlain',
|
||||
],
|
||||
orgCanDisable: false,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
await api.storage.local.set({ [ADMIN_AUTH_KEY]: config });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Verify the admin password.
|
||||
*
|
||||
* @param {string} adminPassword
|
||||
* @returns {boolean}
|
||||
*/
|
||||
async verify(adminPassword) {
|
||||
const result = await api.storage.local.get(ADMIN_AUTH_KEY);
|
||||
const config = result[ADMIN_AUTH_KEY];
|
||||
if (!config?.enabled) return true; // not enabled = always passes
|
||||
|
||||
const salt = Uint8Array.from(atob(config.salt), c => c.charCodeAt(0));
|
||||
const hash = await this._hashPassword(adminPassword, salt);
|
||||
|
||||
return hash === config.passwordHash;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a specific action requires admin authentication.
|
||||
*
|
||||
* @param {string} action - e.g. 'disable', 'clearData'
|
||||
* @returns {boolean}
|
||||
*/
|
||||
async isActionProtected(action) {
|
||||
const result = await api.storage.local.get(ADMIN_AUTH_KEY);
|
||||
const config = result[ADMIN_AUTH_KEY];
|
||||
if (!config?.enabled) return false;
|
||||
return (config.protectedActions || []).includes(action);
|
||||
},
|
||||
|
||||
/**
|
||||
* Require admin authentication for an action.
|
||||
* Returns true if auth passes (or isn't needed), false if denied.
|
||||
*
|
||||
* @param {string} action
|
||||
* @param {string} adminPassword
|
||||
* @returns {{ allowed: boolean, reason?: string }}
|
||||
*/
|
||||
async requireAuth(action, adminPassword) {
|
||||
const isProtected = await this.isActionProtected(action);
|
||||
if (!isProtected) return { allowed: true };
|
||||
|
||||
if (!adminPassword) {
|
||||
return { allowed: false, reason: 'Admin password required.' };
|
||||
}
|
||||
|
||||
const valid = await this.verify(adminPassword);
|
||||
if (!valid) {
|
||||
return { allowed: false, reason: 'Wrong admin password.' };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable tamper protection.
|
||||
* Requires the admin password unless org policy allows disabling.
|
||||
*
|
||||
* @param {string} adminPassword
|
||||
* @returns {{ success: boolean, reason?: string }}
|
||||
*/
|
||||
async disable(adminPassword) {
|
||||
// Check org policy
|
||||
const orgPolicy = await this._getOrgPolicy();
|
||||
if (orgPolicy && orgPolicy.disableTamperProtection === false) {
|
||||
return {
|
||||
success: false,
|
||||
reason: 'Organization policy prevents disabling tamper protection.',
|
||||
};
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const valid = await this.verify(adminPassword);
|
||||
if (!valid) {
|
||||
return { success: false, reason: 'Wrong admin password.' };
|
||||
}
|
||||
|
||||
await api.storage.local.remove(ADMIN_AUTH_KEY);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Change the admin password.
|
||||
*
|
||||
* @param {string} oldPassword
|
||||
* @param {string} newPassword
|
||||
* @returns {{ success: boolean, reason?: string }}
|
||||
*/
|
||||
async changePassword(oldPassword, newPassword) {
|
||||
const valid = await this.verify(oldPassword);
|
||||
if (!valid) {
|
||||
return { success: false, reason: 'Wrong current admin password.' };
|
||||
}
|
||||
|
||||
if (!newPassword || newPassword.length < 4) {
|
||||
return { success: false, reason: 'New password must be at least 4 characters.' };
|
||||
}
|
||||
|
||||
const result = await api.storage.local.get(ADMIN_AUTH_KEY);
|
||||
const config = result[ADMIN_AUTH_KEY];
|
||||
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
|
||||
config.passwordHash = await this._hashPassword(newPassword, salt);
|
||||
config.salt = btoa(String.fromCharCode(...salt));
|
||||
|
||||
await api.storage.local.set({ [ADMIN_AUTH_KEY]: config });
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hash a password with PBKDF2-SHA256.
|
||||
* Returns base64-encoded hash string.
|
||||
*/
|
||||
async _hashPassword(password, salt) {
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits']
|
||||
);
|
||||
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
iterations: ITERATIONS,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
keyMaterial,
|
||||
256
|
||||
);
|
||||
|
||||
return btoa(String.fromCharCode(...new Uint8Array(bits)));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get org policy (if any) to check tamper protection override.
|
||||
*/
|
||||
async _getOrgPolicy() {
|
||||
try {
|
||||
const result = await api.storage.local.get('ss_org_policy');
|
||||
return result.ss_org_policy || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default TamperGuard;
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Silent Send - Version History Manager
|
||||
*
|
||||
* Manages sync version history using IndexedDB. Stores snapshots of the
|
||||
* extension's data (identity, mappings, settings) so the user can
|
||||
* rollback to a previous state after a sync overwrites local data.
|
||||
*
|
||||
* Each snapshot records the source that triggered it (e.g. 'gist',
|
||||
* 'browser-sync', 'rollback') and a full copy of the data at that
|
||||
* point in time. Old snapshots are automatically pruned to stay within
|
||||
* a configurable maximum (default 10).
|
||||
*
|
||||
* Database: IndexedDB 'ss_version_history', version 1
|
||||
* Object store: 'snapshots' (autoIncrement keyPath 'id', index on 'timestamp')
|
||||
*/
|
||||
|
||||
const DB_NAME = 'ss_version_history';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'snapshots';
|
||||
const DEFAULT_MAX_SNAPSHOTS = 10;
|
||||
|
||||
const VersionHistory = {
|
||||
// ----------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Open (or create/upgrade) the IndexedDB database.
|
||||
* @returns {Promise<IDBDatabase>}
|
||||
*/
|
||||
async _openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, {
|
||||
keyPath: 'id',
|
||||
autoIncrement: true,
|
||||
});
|
||||
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Public API
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Save a snapshot of the current extension data.
|
||||
*
|
||||
* @param {Object} data - The data payload to snapshot.
|
||||
* @param {string} data.version - Data schema version.
|
||||
* @param {number} data.lastModified - Epoch ms of the data.
|
||||
* @param {Object} data.identity - Identity fields.
|
||||
* @param {Array} data.mappings - Substitution mappings.
|
||||
* @param {Object} data.settings - Extension settings.
|
||||
* @param {string} source - Origin of the snapshot, e.g.
|
||||
* 'local'|'gist'|'url'|'browser-sync'|'file'|'code'|'rollback'.
|
||||
* @returns {Promise<number>} The auto-generated snapshot id.
|
||||
*/
|
||||
async saveSnapshot(data, source) {
|
||||
const db = await this._openDB();
|
||||
const snapshot = {
|
||||
timestamp: Date.now(),
|
||||
source,
|
||||
data,
|
||||
};
|
||||
|
||||
const id = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.add(snapshot);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
|
||||
// Prune old snapshots beyond the configured maximum
|
||||
const maxVersions = data?.settings?.maxVersionHistory ?? DEFAULT_MAX_SNAPSHOTS;
|
||||
await this.pruneToMax(maxVersions);
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve all snapshots, sorted newest first.
|
||||
* @returns {Promise<Array>} Array of snapshot objects.
|
||||
*/
|
||||
async getSnapshots() {
|
||||
const db = await this._openDB();
|
||||
const snapshots = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
|
||||
// Sort newest first by timestamp (fallback to id descending)
|
||||
snapshots.sort((a, b) => b.timestamp - a.timestamp || b.id - a.id);
|
||||
return snapshots;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve a single snapshot by id.
|
||||
* @param {number} id - The snapshot id.
|
||||
* @returns {Promise<Object|undefined>} The snapshot, or undefined.
|
||||
*/
|
||||
async getSnapshot(id) {
|
||||
const db = await this._openDB();
|
||||
const snapshot = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a single snapshot by id.
|
||||
* @param {number} id - The snapshot id to remove.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deleteSnapshot(id) {
|
||||
const db = await this._openDB();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
},
|
||||
|
||||
/**
|
||||
* Keep only the N newest snapshots, deleting the rest.
|
||||
* @param {number} [maxVersions] - Maximum snapshots to retain
|
||||
* (defaults to DEFAULT_MAX_SNAPSHOTS).
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async pruneToMax(maxVersions) {
|
||||
const max = maxVersions ?? DEFAULT_MAX_SNAPSHOTS;
|
||||
if (max < 1) return;
|
||||
|
||||
const db = await this._openDB();
|
||||
const allSnapshots = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
// Sort newest first, then mark everything beyond `max` for deletion
|
||||
allSnapshots.sort((a, b) => b.timestamp - a.timestamp || b.id - a.id);
|
||||
const toDelete = allSnapshots.slice(max);
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
for (const snap of toDelete) {
|
||||
store.delete(snap.id);
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
db.close();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete all snapshots (wipe version history).
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async clearAll() {
|
||||
const db = await this._openDB();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
|
||||
export default VersionHistory;
|
||||
Reference in New Issue
Block a user