@zanii/mandate-verify-core 0.0.0-stage → 0.3.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/LICENSE ADDED
@@ -0,0 +1,159 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or Derivative
95
+ Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work; and
103
+
104
+ (d) If the Work includes a "NOTICE" text file as part of its
105
+ distribution, then any Derivative Works that You distribute must
106
+ include a readable copy of the attribution notices contained
107
+ within such NOTICE file.
108
+
109
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
110
+ any Contribution intentionally submitted for inclusion in the Work
111
+ by You to the Licensor shall be under the terms and conditions of
112
+ this License, without any additional terms or conditions.
113
+
114
+ 6. Trademarks. This License does not grant permission to use the trade
115
+ names, trademarks, service marks, or product names of the Licensor,
116
+ except as required for reasonable and customary use in describing the
117
+ origin of the Work and reproducing the content of the NOTICE file.
118
+
119
+ 7. Disclaimer of Warranty. Unless required by applicable law or agreed
120
+ to in writing, Licensor provides the Work (and each Contributor
121
+ provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES
122
+ OR CONDITIONS OF ANY KIND, either express or implied, including,
123
+ without limitation, any warranties or conditions of TITLE,
124
+ NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
125
+
126
+ 8. Limitation of Liability. In no event and under no legal theory,
127
+ whether in tort (including negligence), contract, or otherwise,
128
+ unless required by applicable law (such as deliberate and grossly
129
+ negligent acts) or agreed to in writing, shall any Contributor be
130
+ liable to You for damages, including any direct, indirect, special,
131
+ incidental, or consequential damages of any character arising as a
132
+ result of this License or out of the use or inability to use the
133
+ Work.
134
+
135
+ 9. Accepting Warranty or Additional Liability. While redistributing
136
+ the Work or Derivative Works thereof, You may choose to offer,
137
+ and charge a fee for, acceptance of support, warranty, indemnity,
138
+ or other liability obligations and/or rights consistent with this
139
+ License. However, in accepting such obligations, You may act only
140
+ on Your own behalf and on Your sole responsibility, not on behalf
141
+ of any other Contributor, and only if You agree to indemnify,
142
+ defend, and hold each Contributor harmless for any liability
143
+ incurred by, or claims asserted against, such Contributor.
144
+
145
+ END OF TERMS AND CONDITIONS
146
+
147
+ Copyright 2026 Zanii
148
+
149
+ Licensed under the Apache License, Version 2.0 (the "License");
150
+ you may not use this file except in compliance with the License.
151
+ You may obtain a copy of the License at
152
+
153
+ http://www.apache.org/licenses/LICENSE-2.0
154
+
155
+ Unless required by applicable law or agreed to in writing, software
156
+ distributed under the License is distributed on an "AS IS" BASIS,
157
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
158
+ See the License for the specific language governing permissions and
159
+ limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,263 @@
1
- # Temporary Holding Version
1
+ # verify-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pure verification engine. It receives a signed mandate, an action request, a
4
+ status list, and **server-injected** policy/context; it returns a decision. It
5
+ does no database, HTTP or filesystem access. Supply the server clock explicitly through `now` for deterministic evaluation.
6
+
7
+ ## Usage
8
+
9
+ ```js
10
+ import { verifyMandate } from '#verify-core';
11
+
12
+ const result = verifyMandate({
13
+ mandate,
14
+ request: {
15
+ ...action,
16
+ periodCount: usage.count,
17
+ periodAmount: { value: '125.00', currency: 'AED' },
18
+ },
19
+ statusList,
20
+ issuerPublicKey,
21
+ pins: {
22
+ trustedIssuers: configuredTrustedIssuers,
23
+ maxStatusAgeSeconds: 300,
24
+ allowPrincipalMismatch: false,
25
+ },
26
+ delegationChain: [],
27
+ approvals: [],
28
+ now: new Date(),
29
+ });
30
+ ```
31
+
32
+ The production API does not accept `pins`, `now`, usage, a delegation chain, a
33
+ status list, certification status, receipt IDs, or a raw mandate from the caller.
34
+ `Store` loads server state and calls `verifyMandate`; caller attempts to send
35
+ those fields are rejected with `400 UNSUPPORTED_FIELD`. V1 has no certification
36
+ or receipt adapter, so a mandate that requires either fails closed.
37
+
38
+ ## Result
39
+
40
+ ```js
41
+ {
42
+ verdict: 'allow' | 'deny' | 'review' | 'error',
43
+ reasons: [{ code, severity, message, details? }],
44
+ checks: [{ code, ok, severity }],
45
+ status: { status, listId, version, index },
46
+ evaluatedAt: '...'
47
+ }
48
+ ```
49
+
50
+ - `deny`: at least one hard failure.
51
+ - `review`: no hard failure, at least one soft failure.
52
+ - `error`: malformed input or an evaluation exception.
53
+
54
+ `allow` is returned only when every check passes. Soft failures do not authorize. Configure trusted issuer identities independently of the candidate mandate: an empty or missing trusted-root configuration denies authorization. A valid self-contained `did:key` signature establishes key possession, not permission. Use the signature-only primitives when authorization policy is not being evaluated.
55
+
56
+ ## Main checks
57
+
58
+ Schema and version; issuer key resolution; signature; issuer trust; agent and
59
+ principal binding; validity window; status list presence, issuer, reference,
60
+ signature, freshness, version, index, and bit; actions; resources; amount and
61
+ count limits; data class; jurisdiction; time window; approvals; delegation;
62
+ certification; evidence/receipts.
63
+
64
+ ## Any action, any business
65
+
66
+ These optional constraint fields are evaluated when present; mandates without
67
+ them decide exactly as before.
68
+
69
+ | Field | Effect | Reason codes |
70
+ |---|---|---|
71
+ | `deniedActions`, `deniedResources` (patterns), `deniedDataClasses` | A match is always a hard deny, even when `actions`/`resources` allow it. An unknown request data class gives review. | `ACTION_DENIED`, `RESOURCE_DENIED`, `DATA_CLASS_DENIED`, `DATA_CLASS_UNKNOWN` |
72
+ | `maxCount.period: minute \| hour` | UTC calendar windows counted by the server, like `day`/`month`. | `COUNT_LIMIT_EXCEEDED`, `PERIOD_COUNT_UNKNOWN` |
73
+ | `approvalAbove: { amount?, count?, riskTier?, approvals?, approver?, minAcr? }` | Above any limit (an unknown amount, count or vocabulary tier counts as above) the request needs approval through the same flow as `requiredApprovals`: review, or deny when `pins.requireApproval` is true. | `APPROVAL_*`, `SEPARATION_OF_DUTIES` |
74
+ | `revocation.maxStatusAgeSeconds` | The mandate's own status freshness budget; status is then always required. Validity above 7 days is accepted only with it and only up to `pins.maxValidityDays` (1 to 365). | `STATUS_STALE`, `STATUS_MISSING`, `VALIDITY_EXCEEDS_POLICY` |
75
+ | `conditions: [{ type, params? }]` | Checked by condition providers. | `CONDITION_PROVIDER_MISSING`, `CONDITION_NOT_MET`, `CONDITION_ERROR` |
76
+
77
+ Conditions use async providers, `{ [type]: async ({ mandate, request, condition, now }) => ({ ok, reason?, evidence? }) }`:
78
+
79
+ ```js
80
+ import { verifyMandateAsync } from '#verify-core';
81
+
82
+ const result = await verifyMandateAsync({ ...options, conditionProviders, conditionTimeoutMs: 2000 });
83
+ // result.conditions: [{ index, type, ok, code?, reason?, evidence? }]
84
+ ```
85
+
86
+ Providers run only after schema and signature checks pass. A missing provider,
87
+ `ok: false`, a throw, a malformed result or a timeout (default 2000 ms) denies.
88
+ The synchronous `verifyMandate` accepts precomputed `conditionResults` from
89
+ `evaluateConditions`; each result is bound to the exact signed condition (index,
90
+ type and canonical params). Without results a conditioned mandate is denied.
91
+ The request fields `dataSubject`, `counterpartyDid`, `dataRegion`, `destination`
92
+ and `transferSafeguard` (strings, at most 512 characters) pass through to providers.
93
+
94
+ `approvalAbove.riskTier` triggers for actions whose vocabulary tier ranks above
95
+ the given tier, and for free action names with no tier. The default server
96
+ configuration (`packages/config`) sets `requireApproval: true`, so through
97
+ `POST /v1/verify` a missing approval is a deny; the operation flow holds the
98
+ action for review instead.
99
+
100
+ Delegation keeps every restriction: a child must cover each parent deny, keep
101
+ each parent condition, keep an `approvalAbove` that is at least as strict, keep
102
+ the same count period, and keep a freshness budget no weaker than the parent's.
103
+ `inheritParentRestrictions` copies parent denies, conditions and the freshness
104
+ budget into child issuance input.
105
+
106
+ ## Delegation
107
+
108
+ `validateDelegationChain` lives in `src/delegation.js`. It checks that the child
109
+ scope is a subset of the parent: actions, resources, amount/period, count/period,
110
+ jurisdictions, data classes, time window, required approvals, certification
111
+ level/scope, evidence confidence, expiry, depth, and the parent signature. It is
112
+ exported separately for tests and for callers that want to validate a chain
113
+ without a full request.
114
+
115
+ Supply root-to-nearest-parent entries as `{ mandate, statusList }` with each parent's signed status envelope and independently configured root trust. Every link binds the parent ID, authorized child issuer, validity, depth and exact logical status-list reference. Numeric status claims, per-item key overrides and chains embedded in the action request do not establish authority. Supported references are the logical list ID or its `urn:zanii:status:` encoding; arbitrary URI suffixes do not bind a list. Stable-DID parent/status keys can be resolved through server-owned `resolveMandatePublicKey` and `resolveStatusPublicKey` pin callbacks; request- or artifact-supplied callbacks are not trusted context.
116
+
117
+ ## Root passports
118
+
119
+ `verifyPassport` checks an agent's mandate all the way up to a root authority (for example a
120
+ free zone), offline. The root signs one passport mandate to the firm's issuer DID. The firm then
121
+ issues its own tree (company, role, release, instance) with ordinary delegation, so it never
122
+ needs the root to sign anything else.
123
+
124
+ ```js
125
+ import { verifyPassport } from '#verify-core';
126
+
127
+ const result = verifyPassport({
128
+ passport, // signed by rootDid, agent = the firm's issuer DID, delegation allowed
129
+ passportStatusList, // the root's signed status list
130
+ rootDid,
131
+ mandate, // the agent instance's mandate
132
+ statusList,
133
+ delegationChain, // the firm's chain, top first: [{ mandate, statusList }, ...]
134
+ request: { agentDid, action, resource },
135
+ });
136
+ ```
137
+
138
+ It denies when the passport is not from `rootDid` (`PASSPORT_ROOT_UNTRUSTED`), is not a root
139
+ (`PASSPORT_NOT_ROOT`), names a different issuer (`PASSPORT_ISSUER_MISMATCH`), does not allow
140
+ issuing (`PASSPORT_NOT_DELEGABLE`), or when the firm's top mandate exceeds it
141
+ (`PASSPORT_SCOPE_EXCEEDED`). Then it runs `verifyMandate` twice: the passport against the
142
+ request with the root as the only trusted issuer (reasons tagged `scope: 'passport'`), and the
143
+ firm's chain with the firm's issuer as the only trusted issuer. Revoking any level, from the
144
+ passport down to the instance, denies everything below it. Budgets on the passport cannot be
145
+ counted offline, so they give `review`, not `allow`.
146
+
147
+ ## Offline trust bundles
148
+
149
+ `createOfflineTrustBundle` in `#db` reads an initialized control store and exports
150
+ one root-to-leaf chain. It does not sign a new snapshot or mint an authorization.
151
+ The latest signed status for every artifact must already satisfy the producer's
152
+ freshness bound. `verifyOfflineTrustBundle` is pure: it performs no database,
153
+ filesystem, HTTP, clock mutation, usage, reservation, or approval operation.
154
+
155
+ ```js
156
+ import { createOfflineTrustBundle } from '#db';
157
+ import { verifyOfflineTrustBundle } from '#verify-core';
158
+
159
+ const bundle = await createOfflineTrustBundle({
160
+ store: controlStore,
161
+ mandateId,
162
+ maxStatusAgeSeconds: 25,
163
+ });
164
+
165
+ const result = verifyOfflineTrustBundle({
166
+ bundle,
167
+ request: { agentDid, action: 'supplier.read', resource: 'supplier:one' },
168
+ trustAnchors: configuredAnchors,
169
+ statusCheckpoints: durableCheckpoints,
170
+ maxStatusAgeSeconds: 25,
171
+ now: new Date(),
172
+ });
173
+ ```
174
+
175
+ ### Version 1 format
176
+
177
+ The JSON object has `format: "zanii-offline-trust-bundle"`, a manifest, public
178
+ `issuerKeys`, and `mandates`. The manifest binds the root issuer DID,
179
+ `bundleVersion: 1`, `createdAt`, and `contentHash`. The hash is SHA-256 over the
180
+ canonical bundle with `manifest.contentHash` removed. Each `mandates` entry has:
181
+
182
+ - `artifact`: the exact signed mandate JSON string from the control store;
183
+ - `statusEnvelope`: the exact signed status JSON string, including its proof and
184
+ `validFrom`/`validUntil` window.
185
+
186
+ Entries are root-to-leaf and contain the leaf as well as every parent and parent
187
+ status. Public issuer history preserves key ID, DID, purpose, verification
188
+ method, activation, retirement, compromise, and creation timestamps. Private
189
+ JWK parameters and signing keys are never exported. The bundle contains no trust
190
+ anchors, trusted-issuer list, verifier pins, usage, approval, or dispatch policy.
191
+ Its content hash detects modification; it is not a signature and does not make
192
+ bundle metadata or key history trustworthy.
193
+
194
+ ### Caller-owned trust and status checkpoints
195
+
196
+ `trustAnchors` is mandatory caller configuration, never bundle input:
197
+
198
+ ```js
199
+ const configuredAnchors = [{
200
+ issuerDid: 'did:example:issuer',
201
+ keys: [{
202
+ purpose: 'mandate', // or "status"
203
+ verificationMethod: 'did:example:issuer#keyhist_...',
204
+ publicKey: configuredPublicJwk,
205
+ activatedAt: '...',
206
+ retiredAt: null,
207
+ compromisedAt: null,
208
+ }],
209
+ }];
210
+ ```
211
+
212
+ Every mandate and status proof must match a configured key for its exact issuer,
213
+ purpose, verification method, and artifact-time key-history window. The root
214
+ issuer must be explicitly anchored. A valid signature from an unknown issuer,
215
+ or a key copied from `bundle.issuerKeys`, does not establish trust. Stable-DID
216
+ chains need configured key resolution for each signing issuer; delegation still
217
+ has to prove parent-agent authority from the trusted root.
218
+
219
+ Optional `statusCheckpoints` are caller-durable high-water records with `issuer`,
220
+ `listId`, `version`, and `hash`. Older snapshots deny with `STATUS_ROLLBACK`;
221
+ different bytes at an accepted version deny with `STATUS_EQUIVOCATION`. Results
222
+ return one checkpoint per issuer/list only for fresh, authenticated snapshots,
223
+ including a fresh revoked snapshot that correctly denies. Persist those records
224
+ atomically outside the verifier. Stale or unauthenticated results return no new
225
+ checkpoint. Deduplication across a chain does not remove any parent's separate
226
+ signature, status, freshness, or delegation checks.
227
+
228
+ ### What an offline verdict proves, and does not prove
229
+
230
+ The verifier checks the manifest hash, every mandate/status signature against
231
+ caller anchors, schema and expiry, full chain IDs, parent-agent authority,
232
+ cycles, maximum depth, scope attenuation, each parent's fresh signed status,
233
+ leaf status, action/resource scope, and static request constraints. Verdicts are
234
+ `allow`, `deny`, or `review` with machine-readable reasons. Malformed input and
235
+ core evaluation errors fail closed as `deny`.
236
+
237
+ Every result has `offline: true` and `dispatchPermit: false`. `notEstablished`
238
+ always reports that later revocation and operation-bound dispatch authorization
239
+ were not established. An offline `allow` is a static evaluation under fresh
240
+ signed status at `evaluatedAt`; it is not a reusable permit, a reservation, an
241
+ approval release, a spend, or evidence of an external outcome.
242
+
243
+ Count quotas, day/month amount quotas, and applicable required approvals add
244
+ `DYNAMIC_STATE_UNAVAILABLE` and prevent `allow` (normally producing `review`
245
+ when all static checks pass). Certification and required-receipt state are also
246
+ unavailable offline. Caller-supplied period usage, approval IDs, and receipt IDs
247
+ are ignored rather than treated as authoritative. `dynamicState` identifies the
248
+ missing requirements. Transaction-scoped static amount checks and mandates with
249
+ no dynamic requirements can still produce offline `allow`. Current quotas,
250
+ approvals, reservations, and dispatch require the authoritative service inside
251
+ the connected deployment boundary.
252
+
253
+ ## Test
254
+
255
+ ```powershell
256
+ node --test "tests/verify.test.js" "tests/delegation.test.js" "tests/passport.test.js"
257
+ ```
258
+
259
+ ## Releases
260
+
261
+ - **0.3.0**: `verifyPassport` checks an agent's mandate offline through the firm's own tree up to a
262
+ root authority (see "Root passports"). Additive; every 0.2.0 export is unchanged.
263
+ - **0.2.0**: first public release.
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "@zanii/mandate-verify-core",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "exports": "./src/index.js",
6
+ "files": [
7
+ "src",
8
+ "LICENSE",
9
+ "README.md"
10
+ ],
11
+ "dependencies": {
12
+ "@zanii/mandate-core": "0.2.0",
13
+ "@zanii/mandate-policy-engine": "0.2.0",
14
+ "@zanii/mandate-status-list": "0.2.0"
15
+ },
16
+ "engines": {
17
+ "node": ">=24"
18
+ },
19
+ "license": "Apache-2.0",
20
+ "author": "Zanii AI <info@zanii.agency>",
21
+ "homepage": "https://zanii.agency",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ }
25
+ }
@@ -0,0 +1,134 @@
1
+ import { isPlainObject } from '@zanii/mandate-core';
2
+ import { verifyMandate } from './index.js';
3
+
4
+ export const DEFAULT_CONDITION_TIMEOUT_MS = 2000;
5
+ const MAX_EVIDENCE_BYTES = 4096;
6
+ const MAX_REASON_LENGTH = 500;
7
+
8
+ /**
9
+ * Evaluate `constraints.conditions` through registered providers.
10
+ *
11
+ * Providers are `{ [type]: async ({ mandate, request, condition, now }) =>
12
+ * ({ ok, reason?, evidence? }) }`. A missing provider, a provider that says
13
+ * no, throws, times out or returns a malformed result never yields `ok`.
14
+ * Results are passed to `verifyMandate` as `conditionResults`.
15
+ */
16
+ export async function evaluateConditions({
17
+ mandate,
18
+ request,
19
+ providers = {},
20
+ now = new Date(),
21
+ timeoutMs = DEFAULT_CONDITION_TIMEOUT_MS,
22
+ } = {}) {
23
+ const conditions = mandate?.credentialSubject?.constraints?.conditions;
24
+ if (!Array.isArray(conditions) || conditions.length === 0) return [];
25
+ const budget =
26
+ Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_CONDITION_TIMEOUT_MS;
27
+ return Promise.all(
28
+ conditions.map((condition, index) =>
29
+ evaluateOne({ mandate, request, providers, now, timeoutMs: budget }, condition, index),
30
+ ),
31
+ );
32
+ }
33
+
34
+ async function evaluateOne(context, condition, index) {
35
+ const base = { index, type: condition?.type, condition: structuredClone(condition) };
36
+ const provider =
37
+ isPlainObject(context.providers) && Object.hasOwn(context.providers, condition?.type)
38
+ ? context.providers[condition.type]
39
+ : undefined;
40
+ if (typeof provider !== 'function') {
41
+ return {
42
+ ...base,
43
+ ok: false,
44
+ code: 'CONDITION_PROVIDER_MISSING',
45
+ reason: `No condition provider is registered for ${condition?.type}`,
46
+ };
47
+ }
48
+
49
+ let timer;
50
+ try {
51
+ const outcome = await Promise.race([
52
+ Promise.resolve().then(() =>
53
+ provider({
54
+ mandate: structuredClone(context.mandate),
55
+ request: structuredClone(context.request),
56
+ condition: structuredClone(condition),
57
+ now: new Date(context.now),
58
+ }),
59
+ ),
60
+ new Promise((_, reject) => {
61
+ timer = setTimeout(() => reject(new ConditionTimeout()), context.timeoutMs);
62
+ }),
63
+ ]);
64
+ if (!isPlainObject(outcome) || typeof outcome.ok !== 'boolean') {
65
+ return {
66
+ ...base,
67
+ ok: false,
68
+ code: 'CONDITION_ERROR',
69
+ reason: 'Condition provider returned a malformed result',
70
+ };
71
+ }
72
+ const evidence = safeEvidence(outcome.evidence);
73
+ if (outcome.ok) return { ...base, ok: true, ...(evidence ? { evidence } : {}) };
74
+ return {
75
+ ...base,
76
+ ok: false,
77
+ code: 'CONDITION_NOT_MET',
78
+ reason:
79
+ typeof outcome.reason === 'string' && outcome.reason
80
+ ? outcome.reason.slice(0, MAX_REASON_LENGTH)
81
+ : 'Condition is not met',
82
+ ...(evidence ? { evidence } : {}),
83
+ };
84
+ } catch (error) {
85
+ return {
86
+ ...base,
87
+ ok: false,
88
+ code: 'CONDITION_ERROR',
89
+ reason:
90
+ error instanceof ConditionTimeout
91
+ ? `Condition provider timed out after ${context.timeoutMs} ms`
92
+ : 'Condition provider failed',
93
+ };
94
+ } finally {
95
+ clearTimeout(timer);
96
+ }
97
+ }
98
+
99
+ class ConditionTimeout extends Error {}
100
+
101
+ // Evidence is recorded in decisions and audit events: JSON data only, bounded.
102
+ function safeEvidence(evidence) {
103
+ if (evidence === undefined || evidence === null) return null;
104
+ if (!isPlainObject(evidence)) return { invalid: true };
105
+ try {
106
+ const text = JSON.stringify(evidence);
107
+ if (Buffer.byteLength(text, 'utf8') > MAX_EVIDENCE_BYTES) return { truncated: true };
108
+ return JSON.parse(text);
109
+ } catch {
110
+ return { invalid: true };
111
+ }
112
+ }
113
+
114
+ /**
115
+ * `verifyMandate` plus condition providers (option `conditionProviders`,
116
+ * timeout `conditionTimeoutMs`). Providers run only after the signature and
117
+ * schema checks pass, so an unauthenticated artifact never triggers a lookup.
118
+ */
119
+ export async function verifyMandateAsync(options = {}) {
120
+ const conditions = options.mandate?.credentialSubject?.constraints?.conditions;
121
+ if (!Array.isArray(conditions) || conditions.length === 0) return verifyMandate(options);
122
+ const now = options.now ?? new Date();
123
+ const preliminary = verifyMandate({ ...options, now });
124
+ const signed = preliminary.checks.some((check) => check.code === 'SIGNATURE_VALID' && check.ok);
125
+ if (!signed || preliminary.verdict === 'error') return preliminary;
126
+ const conditionResults = await evaluateConditions({
127
+ mandate: options.mandate,
128
+ request: options.request,
129
+ providers: options.conditionProviders,
130
+ now,
131
+ timeoutMs: options.conditionTimeoutMs,
132
+ });
133
+ return verifyMandate({ ...options, now, conditionResults });
134
+ }