@avee1234/worklease 0.1.1
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/LICENSE +21 -0
- package/README.md +186 -0
- package/bin/worklease.js +671 -0
- package/package.json +39 -0
- package/src/adapters/git-hook.js +164 -0
- package/src/check.js +43 -0
- package/src/claim.js +70 -0
- package/src/conformance.js +82 -0
- package/src/glob.js +92 -0
- package/src/index.js +39 -0
- package/src/registry.js +233 -0
- package/src/schema.js +230 -0
package/src/registry.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// worklease — the append-only registry store.
|
|
2
|
+
//
|
|
3
|
+
// A registry is an append-only JSONL file (one JSON record per line). This
|
|
4
|
+
// module is the single home for the store: a small set of *pure* functions
|
|
5
|
+
// (canonical serialization, content-hash ids, log resolution) plus a thin I/O
|
|
6
|
+
// layer (`appendRecord`, `loadRegistry`) that keeps every filesystem access in
|
|
7
|
+
// one place. The design is deliberately lock-free — writes are single-line
|
|
8
|
+
// `O_APPEND` writes, reads fold the whole log into the current claim array, and
|
|
9
|
+
// every record self-identifies by a content hash, so concurrent or duplicated
|
|
10
|
+
// appends resolve cleanly instead of conflicting. Node's built-in `crypto` is
|
|
11
|
+
// the only "dependency"; there are zero runtime packages.
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
|
|
17
|
+
// --- pure core -------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
// canonicalize(record) → deterministic JSON string with sorted keys and no
|
|
20
|
+
// incidental whitespace, over the record EXCLUDING its own `id`. Recurses so
|
|
21
|
+
// key ordering can never perturb the digest at any depth. Used only as the hash
|
|
22
|
+
// pre-image, so it does not need to round-trip to a value.
|
|
23
|
+
export function canonicalize(record) {
|
|
24
|
+
const { id: _id, ...rest } = record;
|
|
25
|
+
return stableStringify(rest);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stableStringify(value) {
|
|
29
|
+
if (Array.isArray(value)) {
|
|
30
|
+
return "[" + value.map(stableStringify).join(",") + "]";
|
|
31
|
+
}
|
|
32
|
+
if (value !== null && typeof value === "object") {
|
|
33
|
+
const keys = Object.keys(value).sort();
|
|
34
|
+
return (
|
|
35
|
+
"{" +
|
|
36
|
+
keys.map((k) => JSON.stringify(k) + ":" + stableStringify(value[k])).join(",") +
|
|
37
|
+
"}"
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return JSON.stringify(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// computeRecordId(record) → the sha256 content hash of the record (its `id`
|
|
44
|
+
// excluded). Deterministic and content-addressed: identical content ⇒ identical
|
|
45
|
+
// id, so a duplicated append is idempotent on read and a tampered line no longer
|
|
46
|
+
// matches its own id. `claim` (#2) uses this same helper so a claim's `id` IS its
|
|
47
|
+
// content hash — one shared implementation across every record type.
|
|
48
|
+
export function computeRecordId(record) {
|
|
49
|
+
return createHash("sha256").update(canonicalize(record)).digest("hex");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// shortId(id) → first 8 hex chars, the compact id `list` shows and `release`
|
|
53
|
+
// accepts as a prefix.
|
|
54
|
+
export function shortId(id) {
|
|
55
|
+
return typeof id === "string" ? id.slice(0, 8) : String(id);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// formatRelative(expires, now) → a short human relative expiry: "in 40s",
|
|
59
|
+
// "in 12m", "in 3h", or "expired" (also "unknown" for an unparseable value).
|
|
60
|
+
export function formatRelative(expires, now = Date.now()) {
|
|
61
|
+
const ms = Date.parse(expires);
|
|
62
|
+
if (Number.isNaN(ms)) return "unknown";
|
|
63
|
+
const delta = ms - now;
|
|
64
|
+
if (delta <= 0) return "expired";
|
|
65
|
+
const s = Math.round(delta / 1000);
|
|
66
|
+
if (s < 60) return `in ${s}s`;
|
|
67
|
+
const m = Math.round(s / 60);
|
|
68
|
+
if (m < 60) return `in ${m}m`;
|
|
69
|
+
return `in ${Math.round(m / 60)}h`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// listActive(claims) → the subset whose effective status is "active". Pure
|
|
73
|
+
// selector kept here for direct unit testing.
|
|
74
|
+
export function listActive(claims) {
|
|
75
|
+
return claims.filter((c) => c.status === "active");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// resolveRecords(records, { now, expire }) → { claims, notes }
|
|
79
|
+
//
|
|
80
|
+
// Folds an already-parsed append log (order = append order) into the current
|
|
81
|
+
// claim array. Pure and total: `now` (epoch ms) is injected so expiry is
|
|
82
|
+
// deterministic, and no bad field value throws. Nothing is written back — every
|
|
83
|
+
// derived status is computed here at read time.
|
|
84
|
+
//
|
|
85
|
+
// 1. Integrity filter — drop any record whose `id` doesn't equal its own
|
|
86
|
+
// content hash (tamper/corruption), with a note. One bad line never
|
|
87
|
+
// discards the rest of the registry.
|
|
88
|
+
// 2. Fold claims — latest claim record per `id` wins (content-addressed, so
|
|
89
|
+
// normally identical; tolerant of a re-append → idempotent).
|
|
90
|
+
// 3. Apply releases — a `release` record moves its `claim_id` to `released`;
|
|
91
|
+
// an unknown `claim_id` is noted and ignored; a releaser who isn't the
|
|
92
|
+
// holder is noted (advisory ownership hint).
|
|
93
|
+
// 4. Derive TTL expiry — an `active` claim whose `expires <= now` becomes
|
|
94
|
+
// effective status `expired` (derived, with a note); released claims stay
|
|
95
|
+
// released. Skipped when `expire` is false, so the stored claim state
|
|
96
|
+
// (active/released, no wall-clock decay) is returned — `conformance` needs
|
|
97
|
+
// this because it audits *after* short TTLs have elapsed and does its own
|
|
98
|
+
// temporal reasoning against each merge's `at`.
|
|
99
|
+
//
|
|
100
|
+
// Returns the claims sorted by `expires` ascending plus the collected notes.
|
|
101
|
+
export function resolveRecords(records, opts = {}) {
|
|
102
|
+
const { now = Date.now(), expire = true } = opts;
|
|
103
|
+
const notes = [];
|
|
104
|
+
|
|
105
|
+
// 1. Integrity filter (also drops non-objects, which can't self-hash).
|
|
106
|
+
const valid = [];
|
|
107
|
+
records.forEach((r, i) => {
|
|
108
|
+
if (r === null || typeof r !== "object" || Array.isArray(r)) {
|
|
109
|
+
notes.push(`skipped record ${i}: not an object`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (r.id !== computeRecordId(r)) {
|
|
113
|
+
notes.push(`skipped record ${i}: id/content mismatch`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
valid.push(r);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// 2. Fold claims / collect releases (a record is a release iff type ===
|
|
120
|
+
// "release"; a claim iff no type or type === "claim"; anything else is
|
|
121
|
+
// forward-compat noise, skipped).
|
|
122
|
+
const claims = new Map();
|
|
123
|
+
const releases = [];
|
|
124
|
+
for (const r of valid) {
|
|
125
|
+
const type = r.type == null ? "claim" : r.type;
|
|
126
|
+
if (type === "claim") {
|
|
127
|
+
claims.set(r.id, { ...r });
|
|
128
|
+
} else if (type === "release") {
|
|
129
|
+
releases.push(r);
|
|
130
|
+
} else {
|
|
131
|
+
notes.push(`skipped record ${shortId(r.id)}: unknown type "${r.type}"`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 3. Apply releases.
|
|
136
|
+
for (const rel of releases) {
|
|
137
|
+
const claim = claims.get(rel.claim_id);
|
|
138
|
+
if (!claim) {
|
|
139
|
+
notes.push(`release for unknown claim_id ${shortId(rel.claim_id)} ignored`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
claim.status = "released";
|
|
143
|
+
claim.released_by = rel.agent;
|
|
144
|
+
claim.released_at = rel.at;
|
|
145
|
+
if (rel.agent !== claim.agent) {
|
|
146
|
+
notes.push(
|
|
147
|
+
`claim ${shortId(claim.id)} released by ${rel.agent}, held by ${claim.agent}`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 4. Derive TTL expiry (a claim exactly at expires === now counts as expired).
|
|
153
|
+
for (const claim of expire ? claims.values() : []) {
|
|
154
|
+
if (claim.status === "active" && claim.expires != null) {
|
|
155
|
+
const exp = Date.parse(claim.expires);
|
|
156
|
+
if (!Number.isNaN(exp) && exp <= now) {
|
|
157
|
+
claim.status = "expired";
|
|
158
|
+
notes.push(`claim ${shortId(claim.id)} expired at ${claim.expires}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const resolved = [...claims.values()].sort(byExpires);
|
|
164
|
+
return { claims: resolved, notes };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Sort by `expires` ascending; unparseable/absent expiries sort last, stably.
|
|
168
|
+
function byExpires(a, b) {
|
|
169
|
+
const ea = Date.parse(a.expires);
|
|
170
|
+
const eb = Date.parse(b.expires);
|
|
171
|
+
const na = Number.isNaN(ea);
|
|
172
|
+
const nb = Number.isNaN(eb);
|
|
173
|
+
if (na && nb) return 0;
|
|
174
|
+
if (na) return 1;
|
|
175
|
+
if (nb) return -1;
|
|
176
|
+
return ea - eb;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- I/O layer -------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
// defaultRegistryPath(cwd) → the ONE place the default registry location is
|
|
182
|
+
// defined; `check`, `list`, and `release` all call it. `WORKLEASE_REGISTRY`
|
|
183
|
+
// overrides, else a git-tracked `.worklease/registry.jsonl` at the repo root.
|
|
184
|
+
export function defaultRegistryPath(cwd = process.cwd()) {
|
|
185
|
+
return process.env.WORKLEASE_REGISTRY || join(cwd, ".worklease", "registry.jsonl");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// appendRecord(path, record) → the stored record (with its content-hash `id`).
|
|
189
|
+
// Assigns `id` if absent, creates the parent directory, then appends exactly one
|
|
190
|
+
// JSON line terminated by "\n" with the `"a"` flag (O_APPEND). Existing lines are
|
|
191
|
+
// never rewritten — this is the whole safety story. Used by `release` here and by
|
|
192
|
+
// `claim` (#2).
|
|
193
|
+
export function appendRecord(path, record) {
|
|
194
|
+
const stored = record.id ? record : { ...record, id: computeRecordId(record) };
|
|
195
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
196
|
+
appendFileSync(path, JSON.stringify(stored) + "\n");
|
|
197
|
+
return stored;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// loadRegistry(path, { now, expire }) → { claims, notes }
|
|
201
|
+
//
|
|
202
|
+
// Reads the JSONL file (missing file → empty registry, no throw), parses each
|
|
203
|
+
// non-blank line tolerantly (a line that won't parse is dropped with a note,
|
|
204
|
+
// never aborting the load), and returns the resolved current registry via
|
|
205
|
+
// `resolveRecords`. `expire` (default true) forwards to `resolveRecords`; pass
|
|
206
|
+
// false to skip wall-clock TTL decay and see the stored claim state. Replaces
|
|
207
|
+
// #3's interim reader.
|
|
208
|
+
export function loadRegistry(path, opts = {}) {
|
|
209
|
+
const { now = Date.now(), expire = true } = opts;
|
|
210
|
+
|
|
211
|
+
let raw;
|
|
212
|
+
try {
|
|
213
|
+
raw = readFileSync(path, "utf8");
|
|
214
|
+
} catch (e) {
|
|
215
|
+
if (e && e.code === "ENOENT") return { claims: [], notes: [] };
|
|
216
|
+
throw e;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const parsed = [];
|
|
220
|
+
const parseNotes = [];
|
|
221
|
+
raw.split("\n").forEach((line, i) => {
|
|
222
|
+
const trimmed = line.trim();
|
|
223
|
+
if (!trimmed) return;
|
|
224
|
+
try {
|
|
225
|
+
parsed.push(JSON.parse(trimmed));
|
|
226
|
+
} catch {
|
|
227
|
+
parseNotes.push(`skipped unparseable line ${i + 1}`);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const resolved = resolveRecords(parsed, { now, expire });
|
|
232
|
+
return { claims: resolved.claims, notes: [...parseNotes, ...resolved.notes] };
|
|
233
|
+
}
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// worklease — claim + registry schema and validators.
|
|
2
|
+
//
|
|
3
|
+
// Pure, zero-dependency validators for the open `claim` shape and a registry
|
|
4
|
+
// (array of claims). Neither function throws on bad input; both return
|
|
5
|
+
// `{ valid, errors }` and collect *every* violation (no short-circuit) so a
|
|
6
|
+
// harness or human can fix everything in one pass.
|
|
7
|
+
//
|
|
8
|
+
// Error = { path: string, code: string, message: string }
|
|
9
|
+
// path — dot/bracket path to the offending value ("globs[0]", "[2].ttl_seconds",
|
|
10
|
+
// or "" for the whole object).
|
|
11
|
+
// code — a stable machine-readable code from ERROR_CODES.
|
|
12
|
+
// message — one-line human explanation.
|
|
13
|
+
|
|
14
|
+
export const STATUSES = ["active", "released", "expired"];
|
|
15
|
+
|
|
16
|
+
// The exact set of allowed top-level claim fields, in canonical order.
|
|
17
|
+
export const CLAIM_FIELDS = [
|
|
18
|
+
"id",
|
|
19
|
+
"agent",
|
|
20
|
+
"globs",
|
|
21
|
+
"intent",
|
|
22
|
+
"ttl_seconds",
|
|
23
|
+
"created",
|
|
24
|
+
"expires",
|
|
25
|
+
"status",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
export const ERROR_CODES = {
|
|
29
|
+
MISSING_FIELD: "MISSING_FIELD",
|
|
30
|
+
UNKNOWN_FIELD: "UNKNOWN_FIELD",
|
|
31
|
+
WRONG_TYPE: "WRONG_TYPE",
|
|
32
|
+
NOT_OBJECT: "NOT_OBJECT",
|
|
33
|
+
NOT_ARRAY: "NOT_ARRAY",
|
|
34
|
+
EMPTY_STRING: "EMPTY_STRING",
|
|
35
|
+
EMPTY_ARRAY: "EMPTY_ARRAY",
|
|
36
|
+
INVALID_ENUM: "INVALID_ENUM",
|
|
37
|
+
NOT_POSITIVE_INT: "NOT_POSITIVE_INT",
|
|
38
|
+
INVALID_ISO8601: "INVALID_ISO8601",
|
|
39
|
+
EXPIRES_MISMATCH: "EXPIRES_MISMATCH",
|
|
40
|
+
INVALID_GLOB: "INVALID_GLOB",
|
|
41
|
+
DUPLICATE_ID: "DUPLICATE_ID",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Strict ISO-8601 UTC: YYYY-MM-DDTHH:MM:SS(.sss)?Z. The regex gates the format
|
|
45
|
+
// (UTC `Z` only, no offsets); Date.parse gates real-calendar validity so
|
|
46
|
+
// impossible dates like 2026-13-40T00:00:00Z are rejected.
|
|
47
|
+
const ISO8601_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
|
|
48
|
+
|
|
49
|
+
// Unsupported glob metacharacters for the documented v0.1 subset. Supported
|
|
50
|
+
// tokens are `**`, `*`, `/`, and literal path characters; `? [ ] { }` are
|
|
51
|
+
// rejected so a malformed glob is caught at claim time rather than silently
|
|
52
|
+
// mis-matching later in `check`.
|
|
53
|
+
const UNSUPPORTED_GLOB_CHARS = /[?[\]{}]/;
|
|
54
|
+
|
|
55
|
+
export function isIso8601Utc(s) {
|
|
56
|
+
if (typeof s !== "string" || !ISO8601_UTC.test(s)) return false;
|
|
57
|
+
const ms = Date.parse(s);
|
|
58
|
+
if (Number.isNaN(ms)) return false;
|
|
59
|
+
// Date.parse silently rolls over impossible calendar dates (e.g.
|
|
60
|
+
// 2026-02-30 → Mar 2, 2026-04-31 → May 1) instead of returning NaN, so a
|
|
61
|
+
// format-valid but nonexistent date would slip through. Round-trip the
|
|
62
|
+
// parsed value and require the calendar portion to match the input.
|
|
63
|
+
return new Date(ms).toISOString().slice(0, 10) === s.slice(0, 10);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isAllowedGlob(s) {
|
|
67
|
+
return typeof s === "string" && s.length > 0 && !UNSUPPORTED_GLOB_CHARS.test(s);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function err(path, code, message) {
|
|
71
|
+
return { path, code, message };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isPlainObject(v) {
|
|
75
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isNonEmptyString(v) {
|
|
79
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Validate a single string field: present-ness is checked by the caller; here
|
|
83
|
+
// we type/emptiness-check a value that exists.
|
|
84
|
+
function checkStringField(errors, obj, field) {
|
|
85
|
+
const v = obj[field];
|
|
86
|
+
if (typeof v !== "string") {
|
|
87
|
+
errors.push(err(field, ERROR_CODES.WRONG_TYPE, `${field} must be a string`));
|
|
88
|
+
} else if (v.trim().length === 0) {
|
|
89
|
+
errors.push(err(field, ERROR_CODES.EMPTY_STRING, `${field} must not be empty`));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function validateClaim(obj) {
|
|
94
|
+
// 1. Must be a non-null plain object.
|
|
95
|
+
if (!isPlainObject(obj)) {
|
|
96
|
+
return {
|
|
97
|
+
valid: false,
|
|
98
|
+
errors: [err("", ERROR_CODES.NOT_OBJECT, "claim must be a JSON object")],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const errors = [];
|
|
103
|
+
|
|
104
|
+
// 2. Required fields present.
|
|
105
|
+
for (const field of CLAIM_FIELDS) {
|
|
106
|
+
if (!(field in obj)) {
|
|
107
|
+
errors.push(err(field, ERROR_CODES.MISSING_FIELD, `${field} is required`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 3. Unknown top-level fields.
|
|
112
|
+
for (const key of Object.keys(obj)) {
|
|
113
|
+
if (!CLAIM_FIELDS.includes(key)) {
|
|
114
|
+
errors.push(err(key, ERROR_CODES.UNKNOWN_FIELD, `unknown field: ${key}`));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 4. Per-field type/shape (only for fields that are present).
|
|
119
|
+
if ("id" in obj) checkStringField(errors, obj, "id");
|
|
120
|
+
if ("agent" in obj) checkStringField(errors, obj, "agent");
|
|
121
|
+
if ("intent" in obj) checkStringField(errors, obj, "intent");
|
|
122
|
+
|
|
123
|
+
if ("globs" in obj) {
|
|
124
|
+
const globs = obj.globs;
|
|
125
|
+
if (!Array.isArray(globs)) {
|
|
126
|
+
errors.push(err("globs", ERROR_CODES.WRONG_TYPE, "globs must be an array"));
|
|
127
|
+
} else if (globs.length === 0) {
|
|
128
|
+
errors.push(err("globs", ERROR_CODES.EMPTY_ARRAY, "globs must have at least one entry"));
|
|
129
|
+
} else {
|
|
130
|
+
globs.forEach((g, i) => {
|
|
131
|
+
if (typeof g !== "string") {
|
|
132
|
+
errors.push(err(`globs[${i}]`, ERROR_CODES.WRONG_TYPE, "glob must be a string"));
|
|
133
|
+
} else if (g.length === 0) {
|
|
134
|
+
errors.push(err(`globs[${i}]`, ERROR_CODES.EMPTY_STRING, "glob must not be empty"));
|
|
135
|
+
} else if (!isAllowedGlob(g)) {
|
|
136
|
+
errors.push(
|
|
137
|
+
err(
|
|
138
|
+
`globs[${i}]`,
|
|
139
|
+
ERROR_CODES.INVALID_GLOB,
|
|
140
|
+
"glob uses unsupported metacharacters (? [ ] { } are not allowed)"
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if ("ttl_seconds" in obj) {
|
|
149
|
+
const t = obj.ttl_seconds;
|
|
150
|
+
if (typeof t !== "number" || !Number.isInteger(t) || t <= 0) {
|
|
151
|
+
errors.push(
|
|
152
|
+
err("ttl_seconds", ERROR_CODES.NOT_POSITIVE_INT, "ttl_seconds must be an integer > 0")
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if ("created" in obj && !isIso8601Utc(obj.created)) {
|
|
158
|
+
errors.push(err("created", ERROR_CODES.INVALID_ISO8601, "created must be ISO 8601 UTC (…Z)"));
|
|
159
|
+
}
|
|
160
|
+
if ("expires" in obj && !isIso8601Utc(obj.expires)) {
|
|
161
|
+
errors.push(err("expires", ERROR_CODES.INVALID_ISO8601, "expires must be ISO 8601 UTC (…Z)"));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if ("status" in obj && !STATUSES.includes(obj.status)) {
|
|
165
|
+
errors.push(
|
|
166
|
+
err("status", ERROR_CODES.INVALID_ENUM, `status must be one of: ${STATUSES.join(", ")}`)
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 5. Cross-field: expires === created + ttl_seconds (only when all three are
|
|
171
|
+
// individually valid, so we don't pile a mismatch on top of a format error).
|
|
172
|
+
if (
|
|
173
|
+
isIso8601Utc(obj.created) &&
|
|
174
|
+
isIso8601Utc(obj.expires) &&
|
|
175
|
+
typeof obj.ttl_seconds === "number" &&
|
|
176
|
+
Number.isInteger(obj.ttl_seconds) &&
|
|
177
|
+
obj.ttl_seconds > 0
|
|
178
|
+
) {
|
|
179
|
+
if (Date.parse(obj.expires) !== Date.parse(obj.created) + obj.ttl_seconds * 1000) {
|
|
180
|
+
errors.push(
|
|
181
|
+
err(
|
|
182
|
+
"expires",
|
|
183
|
+
ERROR_CODES.EXPIRES_MISMATCH,
|
|
184
|
+
"expires must equal created + ttl_seconds"
|
|
185
|
+
)
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { valid: errors.length === 0, errors };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function validateRegistry(arr) {
|
|
194
|
+
// 1. Must be an array.
|
|
195
|
+
if (!Array.isArray(arr)) {
|
|
196
|
+
return {
|
|
197
|
+
valid: false,
|
|
198
|
+
errors: [err("", ERROR_CODES.NOT_ARRAY, "registry must be a JSON array")],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const errors = [];
|
|
203
|
+
|
|
204
|
+
// 2. Per-element validation, re-prefixing each error path with [i].
|
|
205
|
+
arr.forEach((claim, i) => {
|
|
206
|
+
const result = validateClaim(claim);
|
|
207
|
+
for (const e of result.errors) {
|
|
208
|
+
const path = e.path === "" ? `[${i}]` : `[${i}].${e.path}`;
|
|
209
|
+
errors.push(err(path, e.code, e.message));
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// 3. Duplicate id detection among structurally-valid claims. A duplicate id
|
|
214
|
+
// signals corruption or a double-append; flag every occurrence after the
|
|
215
|
+
// first at [i].id.
|
|
216
|
+
const seen = new Set();
|
|
217
|
+
arr.forEach((claim, i) => {
|
|
218
|
+
if (isPlainObject(claim) && isNonEmptyString(claim.id)) {
|
|
219
|
+
if (seen.has(claim.id)) {
|
|
220
|
+
errors.push(
|
|
221
|
+
err(`[${i}].id`, ERROR_CODES.DUPLICATE_ID, `duplicate id: ${claim.id}`)
|
|
222
|
+
);
|
|
223
|
+
} else {
|
|
224
|
+
seen.add(claim.id);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
return { valid: errors.length === 0, errors };
|
|
230
|
+
}
|