@passioncode-ai/fabric-agent-adapter 0.18.0 → 0.19.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.
- package/CHANGELOG.md +61 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +9 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +23 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +3 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +440 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +535 -8
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -54,6 +54,7 @@ const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from f
|
|
|
54
54
|
// DEC-0032: a local path starts with ~/ (or ~\ on Windows), / (never //) or a drive letter; a network share never does.
|
|
55
55
|
const LOCAL_PATH = /^(~[\\/]|\/(?!\/)|[A-Za-z]:[\\/])/;
|
|
56
56
|
export const MANAGERS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'none']);
|
|
57
|
+
const AUTH_HEADER = /^[A-Za-z][A-Za-z0-9-]{0,63}$/;
|
|
57
58
|
const UNIT = /^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$/;
|
|
58
59
|
const TASK = /^\\[A-Za-z0-9 ._\\-]{1,254}$/;
|
|
59
60
|
export const WINDOWS_TRUSTED = Object.freeze(['S-1-5-18', 'S-1-5-32-544']);
|
|
@@ -418,6 +419,8 @@ export function validateDescriptor(d) {
|
|
|
418
419
|
for (const value of [...(d.paths?.data !== undefined ? [d.paths.data] : []), ...(d.paths?.logs ?? [])]) {
|
|
419
420
|
if (!LOCAL_PATH.test(String(value))) problems.push(`paths must be local paths, never a network share: ${JSON.stringify(value)}`);
|
|
420
421
|
}
|
|
422
|
+
if (!(typeof (d.auth?.header ?? 'Authorization') === 'string' && AUTH_HEADER.test(d.auth?.header ?? 'Authorization'))) problems.push(`auth.header must match ${AUTH_HEADER.source}`);
|
|
423
|
+
if (!['Bearer', 'none'].includes(d.auth?.scheme ?? 'Bearer')) problems.push('auth.scheme is Bearer or none');
|
|
421
424
|
if ((d.auth?.header ?? 'Authorization') !== 'Authorization' && (d.auth?.scheme ?? 'Bearer') !== 'none') {
|
|
422
425
|
problems.push('a custom auth header carries the raw token: scheme must be none');
|
|
423
426
|
}
|
|
@@ -658,7 +661,14 @@ function closeRow(row) {
|
|
|
658
661
|
* model; days without calls are omitted. A row whose calls are all unpriced costs null; totals
|
|
659
662
|
* are the sums of the model rows (FAC-SEM-025).
|
|
660
663
|
*/
|
|
661
|
-
|
|
664
|
+
/**
|
|
665
|
+
* `budgets` (DEC-0027) lists every spending limit the service applies, enforced or not. A machine limit with a
|
|
666
|
+
* calendar `period` and no `spentUsd` is counted here from the receipts, the way `budget` is, so the two never
|
|
667
|
+
* disagree; a per-order limit (`per_job`, `approval`) gets `spentUsd: null`; every other limit states its own spend.
|
|
668
|
+
* A report whose limits break FAC-SEM-031 is refused (`usageLimitProblems`).
|
|
669
|
+
*/
|
|
670
|
+
export function usageReport(receipts, { id, instance = 'default', now = Date.now(), budget, budgets } = {}) {
|
|
671
|
+
receipts = Array.from(receipts); // read twice: the days, then a rolling window
|
|
662
672
|
const first = utcDate(now - (USAGE_DAYS - 1) * 86_400_000), last = utcDate(now);
|
|
663
673
|
const days = new Map();
|
|
664
674
|
for (const r of receipts) {
|
|
@@ -689,14 +699,439 @@ export function usageReport(receipts, { id, instance = 'default', now = Date.now
|
|
|
689
699
|
const report = { protocol: PROTOCOL, service: { id, instance }, generatedAt: new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z'), currency: 'USD', days: outDays };
|
|
690
700
|
if (budget !== undefined) {
|
|
691
701
|
if (!['day', 'month'].includes(budget?.period) || typeof budget.limitUsd !== 'number' || !(budget.limitUsd > 0)) throw new ServiceError('budget is {period: day|month, limitUsd > 0}.');
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
report.
|
|
702
|
+
report.budget = { period: budget.period, limitUsd: budget.limitUsd, spentUsd: spentIn(outDays, budget.period, last) };
|
|
703
|
+
}
|
|
704
|
+
if (budgets !== undefined) {
|
|
705
|
+
report.budgets = budgets.map((given) => {
|
|
706
|
+
if (!isPlainObject(given)) throw new ServiceError('Each limit in budgets is an object.');
|
|
707
|
+
const limit = { ...given };
|
|
708
|
+
if (!('spentUsd' in limit)) {
|
|
709
|
+
if (PER_ORDER_KINDS.includes(limit.kind)) limit.spentUsd = null;
|
|
710
|
+
else if (limit.scope === 'machine' && ['day', 'month'].includes(limit.period)) limit.spentUsd = spentIn(outDays, limit.period, last);
|
|
711
|
+
else if (limit.scope === 'machine' && isNumber(limit.windowSeconds)) limit.spentUsd = spentSince(receipts, now - limit.windowSeconds * 1000, now);
|
|
712
|
+
}
|
|
713
|
+
return limit;
|
|
714
|
+
});
|
|
715
|
+
const problems = usageLimitProblems(report);
|
|
716
|
+
if (problems.length) throw new ServiceError(`The report's limits are not coherent: ${problems.join('; ')}`);
|
|
696
717
|
}
|
|
697
718
|
return report;
|
|
698
719
|
}
|
|
699
720
|
|
|
721
|
+
/** Spend in a rolling window, from the receipts' own times; null when every call in it is unpriced. */
|
|
722
|
+
function spentSince(receipts, start, end) {
|
|
723
|
+
const iso = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
724
|
+
const lo = iso(start), hi = iso(end);
|
|
725
|
+
const inside = [...receipts].filter((r) => String(r.at ?? '') > lo && String(r.at ?? '') <= hi);
|
|
726
|
+
if (inside.length && inside.every((r) => r.costUsd === null || r.costUsd === undefined)) return null;
|
|
727
|
+
return Math.round(inside.reduce((n, r) => n + (r.costUsd ?? 0), 0) * 1e6) / 1e6;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Spend in the current UTC day or month; null when every call in it is unpriced. */
|
|
731
|
+
function spentIn(outDays, period, last) {
|
|
732
|
+
const prefix = period === 'day' ? last : last.slice(0, 7);
|
|
733
|
+
const window = outDays.filter((d) => d.date.startsWith(prefix));
|
|
734
|
+
const unknown = window.length > 0 && window.every((d) => d.costUsd === null);
|
|
735
|
+
return unknown ? null : Math.round(window.reduce((n, d) => n + (d.costUsd ?? 0), 0) * 1e6) / 1e6;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// --- every spending limit (DEC-0027, FAC-SEM-031) --------------------------------------------
|
|
739
|
+
|
|
740
|
+
export const LIMIT_SCOPES = Object.freeze(['machine', 'project', 'pool', 'job']);
|
|
741
|
+
export const PER_ORDER_KINDS = Object.freeze(['per_job', 'approval']);
|
|
742
|
+
const LIMIT_ID = /^[a-z0-9][a-z0-9._:-]{0,63}$/;
|
|
743
|
+
const LIMIT_KIND = /^[a-z][a-z0-9_]{0,31}$/;
|
|
744
|
+
const LIMIT_FIELDS = ['id', 'scope', 'subject', 'kind', 'period', 'windowSeconds', 'since', 'limitUsd', 'spentUsd', 'enforced', 'tripped'];
|
|
745
|
+
const TIMESTAMP = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))$/;
|
|
746
|
+
|
|
747
|
+
/** A JSON Schema `date-time` (RFC 3339): a real calendar date and time, `Z` or an offset. */
|
|
748
|
+
export function isTimestamp(value) {
|
|
749
|
+
const m = typeof value === 'string' ? value.match(TIMESTAMP) : null;
|
|
750
|
+
if (!m) return false;
|
|
751
|
+
const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
|
|
752
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
753
|
+
const calendar = date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
|
754
|
+
const offset = m[9] === undefined || (Number(m[9]) <= 23 && Number(m[10]) <= 59);
|
|
755
|
+
return calendar && hour <= 23 && minute <= 59 && second <= 60 && offset;
|
|
756
|
+
}
|
|
757
|
+
const isNumber = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
758
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* The `budgets` of a usage report against `service-usage.schema.json` `$defs/limit` and FAC-SEM-031 — the twin of
|
|
762
|
+
* fabric_service.usage_limit_problems. A breach is not a problem, and an unknown kind is never refused.
|
|
763
|
+
*/
|
|
764
|
+
export function usageLimitProblems(report) {
|
|
765
|
+
const problems = [];
|
|
766
|
+
const limits = report?.budgets;
|
|
767
|
+
if (limits === undefined) return problems;
|
|
768
|
+
if (!Array.isArray(limits) || limits.length > 64) return ['budgets is a list of at most 64 limits'];
|
|
769
|
+
const seen = new Set();
|
|
770
|
+
limits.forEach((limit, i) => {
|
|
771
|
+
const at = `/budgets/${i}`;
|
|
772
|
+
if (!isPlainObject(limit)) { problems.push(`${at} is not an object`); return; }
|
|
773
|
+
for (const key of Object.keys(limit)) if (!LIMIT_FIELDS.includes(key)) problems.push(`${at}/${key} is not a limit field`);
|
|
774
|
+
for (const key of ['id', 'scope', 'kind', 'limitUsd', 'spentUsd', 'enforced']) if (!(key in limit)) problems.push(`${at} is missing ${key}`);
|
|
775
|
+
if ('id' in limit && !(typeof limit.id === 'string' && LIMIT_ID.test(limit.id))) problems.push(`${at}/id must match ${LIMIT_ID.source}`);
|
|
776
|
+
if ('scope' in limit && !LIMIT_SCOPES.includes(limit.scope)) problems.push(`${at}/scope must be one of ${LIMIT_SCOPES.join(', ')}`);
|
|
777
|
+
if ('subject' in limit && !(typeof limit.subject === 'string' && limit.subject.length >= 1 && limit.subject.length <= 128)) problems.push(`${at}/subject is 1 to 128 characters`);
|
|
778
|
+
if ('kind' in limit && !(typeof limit.kind === 'string' && LIMIT_KIND.test(limit.kind))) problems.push(`${at}/kind must match ${LIMIT_KIND.source}`);
|
|
779
|
+
if ('period' in limit && !['day', 'month'].includes(limit.period)) problems.push(`${at}/period is day or month`);
|
|
780
|
+
if ('windowSeconds' in limit && !(Number.isInteger(limit.windowSeconds) && limit.windowSeconds >= 1)) problems.push(`${at}/windowSeconds is a whole number of seconds, at least 1`);
|
|
781
|
+
if ('since' in limit && !isTimestamp(limit.since)) problems.push(`${at}/since is a UTC timestamp`);
|
|
782
|
+
if ('limitUsd' in limit && !(limit.limitUsd === null || (isNumber(limit.limitUsd) && limit.limitUsd > 0))) problems.push(`${at}/limitUsd is a dollar amount above zero, or null`);
|
|
783
|
+
if ('spentUsd' in limit && !(limit.spentUsd === null || (isNumber(limit.spentUsd) && limit.spentUsd >= 0))) problems.push(`${at}/spentUsd is a dollar amount, zero or more, or null`);
|
|
784
|
+
for (const key of ['enforced', 'tripped']) if (key in limit && typeof limit[key] !== 'boolean') problems.push(`${at}/${key} is true or false`);
|
|
785
|
+
|
|
786
|
+
const flag = (where, message) => problems.push(`FAC-SEM-031 ${at}/${where}: ${message}`);
|
|
787
|
+
const id = String(limit.id ?? '');
|
|
788
|
+
if (seen.has(id)) flag('id', `limit id ${id} appears twice`);
|
|
789
|
+
seen.add(id);
|
|
790
|
+
const named = limit.scope === 'project' || limit.scope === 'pool';
|
|
791
|
+
if (named && !('subject' in limit)) flag('subject', `a ${limit.scope} limit names its ${limit.scope} in subject`);
|
|
792
|
+
if (!named && 'subject' in limit) flag('subject', `a ${limit.scope} limit has no subject`);
|
|
793
|
+
const windows = ['period', 'windowSeconds', 'since'].filter((f) => f in limit);
|
|
794
|
+
if (windows.length > 1) flag(windows[1], `a limit has one window, not ${windows.join(' and ')}`);
|
|
795
|
+
const kind = String(limit.kind ?? '');
|
|
796
|
+
const seconds = isNumber(limit.windowSeconds) ? limit.windowSeconds : null;
|
|
797
|
+
if (PER_ORDER_KINDS.includes(kind)) {
|
|
798
|
+
if (windows.length) flag(windows[0], `${kind} bounds one order and has no window`);
|
|
799
|
+
if (limit.spentUsd !== null) flag('spentUsd', `${kind} bounds one order, so nothing is spent against it (null)`);
|
|
800
|
+
} else if (kind === 'daily') {
|
|
801
|
+
if (!(limit.period === 'day' || seconds === 86400)) flag('period', 'a daily limit counts the UTC day (period day) or the last 24 hours (windowSeconds 86400)');
|
|
802
|
+
} else if (kind === 'monthly') {
|
|
803
|
+
if (!(limit.period === 'month' || (seconds !== null && seconds >= 28 * 86400 && seconds <= 31 * 86400))) flag('period', 'a monthly limit counts the UTC month (period month) or a rolling 28–31 days (windowSeconds)');
|
|
804
|
+
} else if (kind === 'velocity') {
|
|
805
|
+
if (seconds === null) flag('windowSeconds', 'a velocity limit counts a rolling window (windowSeconds)');
|
|
806
|
+
} else if (kind === 'pool' || kind === 'emergency') {
|
|
807
|
+
if (!windows.length) flag('period', `a ${kind} limit names the window it counts (period, windowSeconds or since)`);
|
|
808
|
+
}
|
|
809
|
+
const known = PER_ORDER_KINDS.includes(kind) || ['daily', 'monthly', 'pool', 'emergency'].includes(kind);
|
|
810
|
+
if (known && 'limitUsd' in limit && limit.limitUsd === null) flag('limitUsd', `a ${kind} limit is a dollar amount; only a relative limit (velocity) may leave it null`);
|
|
811
|
+
if (limit.tripped === true && limit.enforced !== true) flag('tripped', 'a limit that is not enforced cannot have stopped work');
|
|
812
|
+
});
|
|
813
|
+
const budget = report.budget;
|
|
814
|
+
if (isPlainObject(budget) && limits.length) {
|
|
815
|
+
const kind = budget.period === 'day' ? 'daily' : 'monthly';
|
|
816
|
+
const same = (x, y) => (x === null || y === null ? x === y : isNumber(x) && isNumber(y) && Math.abs(x - y) <= 0.000001);
|
|
817
|
+
const twin = limits.some((l) => isPlainObject(l) && l.scope === 'machine' && l.enforced === true && l.kind === kind && l.period === budget.period && same(l.limitUsd, budget.limitUsd) && same(l.spentUsd, budget.spentUsd));
|
|
818
|
+
if (!twin) problems.push(`FAC-SEM-031 /budget: budget repeats one enforced machine ${kind} limit with period ${budget.period} in budgets[], so old and new readers agree`);
|
|
819
|
+
}
|
|
820
|
+
return problems;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// --- the feed client (DEC-0025, FAC-SEM-036) -------------------------------------------------
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* The one header a client sends the service token in, as the descriptor's `auth` declares it: `header` defaults
|
|
827
|
+
* to Authorization and `scheme` to Bearer; `none` carries the raw token. Never assume `Authorization: Bearer`.
|
|
828
|
+
*/
|
|
829
|
+
export function tokenHeader(auth, token) {
|
|
830
|
+
const declared = isPlainObject(auth) ? auth : {};
|
|
831
|
+
const name = typeof declared.header === 'string' ? declared.header : 'Authorization';
|
|
832
|
+
const scheme = typeof declared.scheme === 'string' ? declared.scheme : 'Bearer';
|
|
833
|
+
return { name, value: scheme === 'none' ? token : `Bearer ${token}` };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// --- settings backups (DEC-0025, FAC-SEM-035) ------------------------------------------------
|
|
837
|
+
// The twins of fabric_service.make_settings_backup and the rest; both kits write byte-identical files.
|
|
838
|
+
|
|
839
|
+
export const SETTINGS_BACKUP_FORMAT = 'fabric-settings-backup/1';
|
|
840
|
+
export const SETTINGS_BACKUP_KEEP = 14;
|
|
841
|
+
export const SETTINGS_BACKUP_MAX_AGE_SECONDS = 24 * 60 * 60;
|
|
842
|
+
const TABLE = /^[a-z][a-z0-9_]{0,62}$/;
|
|
843
|
+
const SECRET_TABLE = /^((.+_)?(account_connections?|account_connection_drafts?|tokens?|secrets?|credentials?|passwords?|login_codes?)|(operator_)?sessions?)$/;
|
|
844
|
+
const COLUMN = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
|
|
845
|
+
const SECRET_COLUMN = /^(?:.+_)?(?:[Tt]oken|[Ss]ecret|[Pp]assword|[Pp]assphrase|[Aa]pi_?[Kk]ey|[Pp]rivate_[Kk]ey|[Cc]ookie|[Ll]ogin_[Cc]ode|[Oo]tp|[Cc]redential)s?$/;
|
|
846
|
+
const REASON = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
847
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
848
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
849
|
+
const URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S*$/;
|
|
850
|
+
const MAX_CELL_CHARS = 1048576;
|
|
851
|
+
const MAX_ROW_VALUES = 512;
|
|
852
|
+
const STALE_TEMP_MS = 60 * 60 * 1000;
|
|
853
|
+
const BACKUP_NAME = /^settings-[0-9]{8}T[0-9]{12}Z\.json$/;
|
|
854
|
+
|
|
855
|
+
function byCodePoint(a, b) {
|
|
856
|
+
const x = Array.from(a), y = Array.from(b);
|
|
857
|
+
for (let i = 0; i < Math.min(x.length, y.length); i += 1) {
|
|
858
|
+
const d = x[i].codePointAt(0) - y[i].codePointAt(0);
|
|
859
|
+
if (d !== 0) return d;
|
|
860
|
+
}
|
|
861
|
+
return x.length - y.length;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* The settings canonical form (DEC-0025, DEC-0043): keys sorted by code point at every level, `", "` and `": "`,
|
|
866
|
+
* strings escaped as JSON requires and nothing else, integers in decimal — Python's
|
|
867
|
+
* `json.dumps(value, sort_keys=True, ensure_ascii=False)`. Refuses a fraction, an unsafe integer and non-JSON.
|
|
868
|
+
*/
|
|
869
|
+
export function settingsCanonicalJson(value) {
|
|
870
|
+
if (value === null) return 'null';
|
|
871
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
872
|
+
if (typeof value === 'number') {
|
|
873
|
+
if (!Number.isSafeInteger(value)) throw new ServiceError(`${value} has no canonical form: a settings backup stores a fraction as its decimal string`);
|
|
874
|
+
return String(value);
|
|
875
|
+
}
|
|
876
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
877
|
+
if (Array.isArray(value)) return `[${value.map(settingsCanonicalJson).join(', ')}]`;
|
|
878
|
+
if (isPlainObject(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))) {
|
|
879
|
+
return `{${Object.keys(value).sort(byCodePoint).map((key) => `${JSON.stringify(key)}: ${settingsCanonicalJson(value[key])}`).join(', ')}}`;
|
|
880
|
+
}
|
|
881
|
+
throw new ServiceError(`${typeof value} is not a JSON value`);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** The `sha256` a settings backup carries: SHA-256 of the UTF-8 canonical JSON of `tables`. */
|
|
885
|
+
export const settingsBackupDigest = (tables) => crypto.createHash('sha256').update(settingsCanonicalJson(tables), 'utf8').digest('hex');
|
|
886
|
+
|
|
887
|
+
/** Why a reader refuses this settings backup: its shape and FAC-SEM-035. Empty means intact. */
|
|
888
|
+
export function settingsBackupProblems(doc) {
|
|
889
|
+
if (!isPlainObject(doc)) return ['a settings backup is a JSON object'];
|
|
890
|
+
const problems = [];
|
|
891
|
+
if (doc.format !== SETTINGS_BACKUP_FORMAT) problems.push(`format must be ${SETTINGS_BACKUP_FORMAT}, not ${JSON.stringify(doc.format ?? null)}: a reader refuses a format it does not know`);
|
|
892
|
+
for (const key of ['format', 'service', 'created_at', 'reason', 'sha256', 'counts', 'tables']) if (!(key in doc)) problems.push(`missing ${key}`);
|
|
893
|
+
for (const key of Object.keys(doc)) if (!['format', 'service', 'created_at', 'reason', 'sha256', 'counts', 'tables', 'extensions'].includes(key)) problems.push(`${key} is not a settings backup field`);
|
|
894
|
+
if ('extensions' in doc) {
|
|
895
|
+
if (!isPlainObject(doc.extensions)) problems.push('extensions is an object');
|
|
896
|
+
else for (const key of Object.keys(doc.extensions)) if (!URI.test(key)) problems.push(`extensions key ${JSON.stringify(key)} is not a URI`);
|
|
897
|
+
}
|
|
898
|
+
if ('service' in doc) {
|
|
899
|
+
const s = doc.service;
|
|
900
|
+
if (!isPlainObject(s) || Object.keys(s).sort().join() !== 'id,instance,schema,version') problems.push('service is {id, instance, version, schema}');
|
|
901
|
+
else {
|
|
902
|
+
if (!ID.test(String(s.id))) problems.push(`service.id must match ${ID.source}`);
|
|
903
|
+
if (!INSTANCE.test(String(s.instance))) problems.push(`service.instance must match ${INSTANCE.source}`);
|
|
904
|
+
if (!(typeof s.version === 'string' && s.version.length <= 64)) problems.push('service.version is a string of at most 64 characters');
|
|
905
|
+
if (!((Number.isInteger(s.schema) && s.schema >= 0) || (typeof s.schema === 'string' && s.schema.length >= 1 && s.schema.length <= 64))) problems.push('service.schema is a whole number or a short string');
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
if ('created_at' in doc && !isTimestamp(doc.created_at)) problems.push('created_at is a UTC timestamp');
|
|
909
|
+
if ('reason' in doc && !(typeof doc.reason === 'string' && REASON.test(doc.reason))) problems.push(`reason must match ${REASON.source}`);
|
|
910
|
+
if ('sha256' in doc && !(typeof doc.sha256 === 'string' && SHA256.test(doc.sha256))) problems.push('sha256 is 64 lowercase hex digits');
|
|
911
|
+
const tables = isPlainObject(doc.tables) ? doc.tables : {};
|
|
912
|
+
const counts = isPlainObject(doc.counts) ? doc.counts : {};
|
|
913
|
+
if ('tables' in doc && !isPlainObject(doc.tables)) problems.push('tables is an object');
|
|
914
|
+
if ('counts' in doc && !isPlainObject(doc.counts)) problems.push('counts is an object');
|
|
915
|
+
let canonical = true;
|
|
916
|
+
for (const name of [...Object.keys(tables), ...Object.keys(counts).filter((n) => !Object.hasOwn(tables, n))]) {
|
|
917
|
+
if (LONE_SURROGATE.test(name)) canonical = false;
|
|
918
|
+
if (!TABLE.test(name) || SECRET_TABLE.test(name)) problems.push(`table ${name} is never backed up: it can only hold connections, tokens, secrets, credentials, passwords, login codes or sessions (or its name is not a table name)`);
|
|
919
|
+
}
|
|
920
|
+
for (const [name, count] of Object.entries(counts)) if (!(Number.isInteger(count) && count >= 0)) problems.push(`counts.${name} is a whole number`);
|
|
921
|
+
for (const [name, table] of Object.entries(tables)) {
|
|
922
|
+
if (!isPlainObject(table) || Object.keys(table).sort().join() !== 'columns,rows') { problems.push(`tables.${name} is {columns, rows}`); continue; }
|
|
923
|
+
let columns = table.columns;
|
|
924
|
+
if (!(Array.isArray(columns) && columns.length >= 1 && columns.length <= 512 && new Set(columns.map(String)).size === columns.length)) {
|
|
925
|
+
problems.push(`tables.${name}.columns is 1 to 512 distinct names`);
|
|
926
|
+
if (!Array.isArray(columns)) columns = [];
|
|
927
|
+
}
|
|
928
|
+
for (const column of columns) {
|
|
929
|
+
if (!(typeof column === 'string' && COLUMN.test(column)) || SECRET_COLUMN.test(String(column))) problems.push(`column ${name}.${column} is refused: its name says it holds a credential value (name the secret, never store it: secret_name, token_slot)`);
|
|
930
|
+
}
|
|
931
|
+
if (!Array.isArray(table.rows)) { problems.push(`tables.${name}.rows is a list`); continue; }
|
|
932
|
+
table.rows.forEach((row, i) => {
|
|
933
|
+
const at = `/tables/${name}/rows/${i}`;
|
|
934
|
+
if (!Array.isArray(row)) { problems.push(`${at} is a list of values`); return; }
|
|
935
|
+
if (row.length !== columns.length) problems.push(`FAC-SEM-035 ${at}: row has ${row.length} values for ${columns.length} columns`);
|
|
936
|
+
if (row.length > MAX_ROW_VALUES) problems.push(`${at} holds more than ${MAX_ROW_VALUES} values`);
|
|
937
|
+
row.forEach((cell, j) => {
|
|
938
|
+
if (typeof cell === 'number' && !Number.isSafeInteger(cell)) {
|
|
939
|
+
canonical = false;
|
|
940
|
+
problems.push(`FAC-SEM-035 ${at}/${j}: a fraction or an unsafe integer has no canonical form; store it as a string`);
|
|
941
|
+
} else if (typeof cell === 'string' && cell.length > MAX_CELL_CHARS) {
|
|
942
|
+
problems.push(`${at}/${j} is longer than ${MAX_CELL_CHARS} characters`);
|
|
943
|
+
} else if (typeof cell === 'string' && LONE_SURROGATE.test(cell)) {
|
|
944
|
+
canonical = false;
|
|
945
|
+
problems.push(`FAC-SEM-035 ${at}/${j}: a string with a lone surrogate cannot be encoded as UTF-8`);
|
|
946
|
+
} else if (!(cell === null || ['boolean', 'number', 'string'].includes(typeof cell))) {
|
|
947
|
+
canonical = false;
|
|
948
|
+
problems.push(`${at}/${j} is not a JSON scalar`);
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
if (counts[name] !== table.rows.length) problems.push(`FAC-SEM-035 /counts/${name}: counts says ${counts[name] ?? 'nothing'} for ${table.rows.length} rows`);
|
|
953
|
+
}
|
|
954
|
+
for (const name of Object.keys(counts)) if (!Object.hasOwn(tables, name)) problems.push(`FAC-SEM-035 /counts/${name}: counts names ${name}, which the backup does not carry`);
|
|
955
|
+
if (canonical && isPlainObject(doc.tables)) {
|
|
956
|
+
let digest = null;
|
|
957
|
+
try {
|
|
958
|
+
digest = settingsBackupDigest(tables);
|
|
959
|
+
} catch (error) {
|
|
960
|
+
problems.push(`FAC-SEM-035 /tables: the tables have no canonical form (${error.message})`);
|
|
961
|
+
}
|
|
962
|
+
if (digest !== null && doc.sha256 !== digest) problems.push('FAC-SEM-035 /sha256: the checksum does not match the tables: the file is damaged and a reader refuses it');
|
|
963
|
+
}
|
|
964
|
+
return problems;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Where every service keeps its settings backups — outside its data directory (DEC-0025). */
|
|
968
|
+
export function settingsBackupRoot(platform = process.platform) {
|
|
969
|
+
if (platform === 'darwin') return path.join(os.homedir(), 'Library/Application Support/PassionCode');
|
|
970
|
+
if (platform === 'win32') return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'PassionCode');
|
|
971
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'PassionCode');
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
export function settingsBackupDir(id, instance = 'default', root = settingsBackupRoot()) {
|
|
975
|
+
if (!ID.test(id)) throw new ServiceError(`Service id ${id} must match ${ID}.`);
|
|
976
|
+
if (!INSTANCE.test(instance)) throw new ServiceError(`instance must match ${INSTANCE}`);
|
|
977
|
+
return path.join(root, 'settings-backups', id, instance);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const microStamp = (ms) => `${new Date(ms).toISOString().slice(0, 23)}000Z`;
|
|
981
|
+
|
|
982
|
+
/** A snapshot of the operator's decisions and bindings (the tables on the service's allow-list). Refuses a file a reader would refuse. */
|
|
983
|
+
export function makeSettingsBackup({ id, instance = 'default', version, schema, tables, reason, now = Date.now() }) {
|
|
984
|
+
const doc = {
|
|
985
|
+
format: SETTINGS_BACKUP_FORMAT, service: { id, instance, version, schema }, created_at: microStamp(now), reason, sha256: '',
|
|
986
|
+
counts: Object.fromEntries(Object.entries(tables).map(([name, table]) => [name, (table?.rows ?? []).length])), tables,
|
|
987
|
+
};
|
|
988
|
+
try {
|
|
989
|
+
doc.sha256 = settingsBackupDigest(tables);
|
|
990
|
+
} catch (error) {
|
|
991
|
+
throw new ServiceError(`This snapshot has no canonical form: ${error.message}`);
|
|
992
|
+
}
|
|
993
|
+
const problems = settingsBackupProblems(doc);
|
|
994
|
+
if (problems.length) throw new ServiceError(`This snapshot would be refused: ${problems.join('; ')}`);
|
|
995
|
+
return doc;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const backupName = (doc) => `settings-${doc.created_at.replace(/[-:]/g, '').replace(/\.(\d+)Z$/, (_, f) => `${(f + '000000').slice(0, 6)}Z`)}.json`;
|
|
999
|
+
|
|
1000
|
+
/** The snapshot files, newest first. */
|
|
1001
|
+
export function settingsBackups(dir) {
|
|
1002
|
+
if (!fs.existsSync(dir)) return [];
|
|
1003
|
+
return fs.readdirSync(dir).filter((n) => BACKUP_NAME.test(n)).sort().reverse().map((n) => path.join(dir, n));
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** The first number written with a fraction or an exponent outside a string (`1.0`, `1e2`); JSON.parse would read
|
|
1007
|
+
* `1.0` as the integer 1, where the Python reader — and the canonical form — sees a fraction. */
|
|
1008
|
+
function fractionLexeme(text) {
|
|
1009
|
+
let inString = false;
|
|
1010
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
1011
|
+
const c = text[i];
|
|
1012
|
+
if (inString) {
|
|
1013
|
+
if (c === '\\') i += 1;
|
|
1014
|
+
else if (c === '"') inString = false;
|
|
1015
|
+
} else if (c === '"') {
|
|
1016
|
+
inString = true;
|
|
1017
|
+
} else if (c === '-' || (c >= '0' && c <= '9')) {
|
|
1018
|
+
const lexeme = text.slice(i).match(/^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?/)[0];
|
|
1019
|
+
if (/[.eE]/.test(lexeme)) return lexeme;
|
|
1020
|
+
i += lexeme.length - 1;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** A snapshot a reader may trust, or ServiceError naming why it is refused (FAC-SEM-035). */
|
|
1027
|
+
export function readSettingsBackup(file) {
|
|
1028
|
+
let doc;
|
|
1029
|
+
try {
|
|
1030
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(file));
|
|
1031
|
+
const fraction = fractionLexeme(text);
|
|
1032
|
+
if (fraction) throw new Error(`${fraction} is not an integer: a fraction is stored as its decimal string`);
|
|
1033
|
+
doc = JSON.parse(text);
|
|
1034
|
+
} catch (error) {
|
|
1035
|
+
throw new ServiceError(`Settings backup ${file} cannot be read: ${error.message}`);
|
|
1036
|
+
}
|
|
1037
|
+
const problems = settingsBackupProblems(doc);
|
|
1038
|
+
if (problems.length) throw new ServiceError(`Settings backup ${file} is refused: ${problems.join('; ')}`);
|
|
1039
|
+
return doc;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Write a snapshot: atomically, 0600 in a 0700 directory, named by its time. A temporary file an interrupted write
|
|
1044
|
+
* left is removed first; a snapshot whose checksum equals the newest file's is not written again (null). After a
|
|
1045
|
+
* write, all but the `keep` newest are removed (at least 14).
|
|
1046
|
+
*/
|
|
1047
|
+
export function writeSettingsBackup(doc, dir, { keep = SETTINGS_BACKUP_KEEP } = {}) {
|
|
1048
|
+
const problems = settingsBackupProblems(doc);
|
|
1049
|
+
if (problems.length) throw new ServiceError(`This snapshot would be refused: ${problems.join('; ')}`);
|
|
1050
|
+
ensurePrivateDir(dir);
|
|
1051
|
+
// Only a temporary file an interrupted write left: one a concurrent writer is still filling is younger.
|
|
1052
|
+
for (const left of fs.readdirSync(dir)) {
|
|
1053
|
+
const at = path.join(dir, left);
|
|
1054
|
+
try {
|
|
1055
|
+
if (left.startsWith('.settings-') && fs.statSync(at).mtimeMs < Date.now() - STALE_TEMP_MS) fs.rmSync(at, { force: true });
|
|
1056
|
+
} catch { /* gone meanwhile */ }
|
|
1057
|
+
}
|
|
1058
|
+
const existing = settingsBackups(dir);
|
|
1059
|
+
if (existing.length) {
|
|
1060
|
+
try {
|
|
1061
|
+
if (readSettingsBackup(existing[0]).sha256 === doc.sha256) return null;
|
|
1062
|
+
} catch { /* a damaged newest file is no reason to skip a good snapshot */ }
|
|
1063
|
+
}
|
|
1064
|
+
const file = path.join(dir, backupName(doc));
|
|
1065
|
+
atomicWrite(file, `${JSON.stringify(doc, null, 2)}\n`, 0o600);
|
|
1066
|
+
// The newest by name, but never the one just written: a clock that stepped back must not delete it.
|
|
1067
|
+
for (const old of settingsBackups(dir).filter((f) => f !== file).slice(Math.max(keep, SETTINGS_BACKUP_KEEP) - 1)) fs.rmSync(old, { force: true });
|
|
1068
|
+
return file;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/** True when there is no snapshot or the newest is older than 24 hours. */
|
|
1072
|
+
export function backupDue(dir, now = Date.now()) {
|
|
1073
|
+
const newest = settingsBackups(dir)[0];
|
|
1074
|
+
if (!newest) return true;
|
|
1075
|
+
const m = path.basename(newest).match(/^settings-(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})\d{3}Z\.json$/);
|
|
1076
|
+
// A name no calendar has: take a fresh snapshot rather than trust it (Date.UTC would roll it over).
|
|
1077
|
+
if (!isTimestamp(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`)) return true;
|
|
1078
|
+
const at = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6], +m[7]);
|
|
1079
|
+
return (now - at) / 1000 > SETTINGS_BACKUP_MAX_AGE_SECONDS;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** The source of an automatic restore: the newest intact snapshot with content; damaged ones are returned with the reason. */
|
|
1083
|
+
export function newestIntactBackup(dir) {
|
|
1084
|
+
const skipped = [];
|
|
1085
|
+
for (const file of settingsBackups(dir)) {
|
|
1086
|
+
let doc;
|
|
1087
|
+
try {
|
|
1088
|
+
doc = readSettingsBackup(file);
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
skipped.push({ file, reason: error.message });
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
if (Object.values(doc.counts).some((n) => n > 0)) return { file, doc, skipped };
|
|
1094
|
+
}
|
|
1095
|
+
return { file: null, doc: null, skipped };
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* What restoring `doc` would do — it adds and never overwrites (DEC-0025). `current[table]` is `{columns, key,
|
|
1100
|
+
* required, existing}` (existing primary keys as arrays). The twin of fabric_service.restore_plan.
|
|
1101
|
+
*/
|
|
1102
|
+
export function restorePlan(doc, current) {
|
|
1103
|
+
const problems = settingsBackupProblems(doc);
|
|
1104
|
+
if (problems.length) throw new ServiceError(`A damaged snapshot is never restored: ${problems.join('; ')}`);
|
|
1105
|
+
const plan = { source: { created_at: doc.created_at, sha256: doc.sha256 }, tables: {}, unknownTables: [] };
|
|
1106
|
+
for (const [name, table] of Object.entries(doc.tables)) {
|
|
1107
|
+
const now = current[name];
|
|
1108
|
+
if (!now) { plan.unknownTables.push(name); continue; }
|
|
1109
|
+
const columns = now.columns ?? [], key = now.key ?? [], required = now.required ?? [];
|
|
1110
|
+
const existing = new Set((now.existing ?? []).map((k) => JSON.stringify([...k])));
|
|
1111
|
+
const shared = table.columns.filter((c) => columns.includes(c));
|
|
1112
|
+
const missing = required.filter((c) => !table.columns.includes(c));
|
|
1113
|
+
const entry = {
|
|
1114
|
+
insert: [], present: 0, droppedColumns: table.columns.filter((c) => !columns.includes(c)),
|
|
1115
|
+
defaultedColumns: columns.filter((c) => !table.columns.includes(c)), missingRequired: missing, refused: [],
|
|
1116
|
+
};
|
|
1117
|
+
const planned = new Set();
|
|
1118
|
+
for (const row of table.rows) {
|
|
1119
|
+
const values = Object.fromEntries(table.columns.map((c, i) => [c, row[i]]));
|
|
1120
|
+
const ident = key.map((c) => values[c] ?? null);
|
|
1121
|
+
if (key.some((c) => !table.columns.includes(c))) entry.refused.push({ key: ident, reason: 'the primary key is not in the snapshot' });
|
|
1122
|
+
else if (planned.has(JSON.stringify(ident))) entry.refused.push({ key: ident, reason: 'the snapshot holds this key twice' });
|
|
1123
|
+
else if (existing.has(JSON.stringify(ident))) entry.present += 1;
|
|
1124
|
+
else if (missing.length) entry.refused.push({ key: ident, reason: `required column ${missing.join(', ')} is not in the snapshot` });
|
|
1125
|
+
else {
|
|
1126
|
+
planned.add(JSON.stringify(ident));
|
|
1127
|
+
entry.insert.push(Object.fromEntries(shared.map((c) => [c, values[c]])));
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
plan.tables[name] = entry;
|
|
1131
|
+
}
|
|
1132
|
+
return plan;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
700
1135
|
/** Usage receipts for services that keep none yet: append-only JSON lines, 0600, pruned to the window (LC-12).
|
|
701
1136
|
* One writer process (the service holds its instance lock); every method is synchronous, so calls never interleave. */
|
|
702
1137
|
export class JsonlUsageLedger {
|