@packvium/engine 0.1.0 → 0.1.2
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/README.md +94 -2
- package/SECURITY.md +77 -0
- package/commerce-model.js +236 -0
- package/commerce.js +664 -0
- package/examples/basic.mjs +67 -0
- package/examples/commerce.mjs +163 -0
- package/examples/objectives.mjs +114 -0
- package/fallback.js +103 -11
- package/index.d.ts +12 -0
- package/index.js +41 -1
- package/package.json +7 -3
- package/policy.js +226 -0
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ production.
|
|
|
15
15
|
## Quick start
|
|
16
16
|
|
|
17
17
|
```js
|
|
18
|
-
import { backend, pack } from '@packvium/engine';
|
|
18
|
+
import { backend, commerce, pack } from '@packvium/engine';
|
|
19
19
|
|
|
20
20
|
const result = pack({
|
|
21
21
|
items: [{
|
|
@@ -31,6 +31,79 @@ const result = pack({
|
|
|
31
31
|
console.log(backend()); // "rust" or "javascript"
|
|
32
32
|
console.log(result.status); // "feasible"
|
|
33
33
|
console.log(result.containers);
|
|
34
|
+
|
|
35
|
+
const commerceDocument = { tariffs: [{
|
|
36
|
+
carrier_id: 'acme', service_id: 'ground',
|
|
37
|
+
versions: [{
|
|
38
|
+
effective_at: 0, dimensional_weight_divisor: 5000,
|
|
39
|
+
cost_per_dimensional_kg_minor: { 'zone-a': 450 },
|
|
40
|
+
minimum_charge_minor: 900, fuel_surcharge_permille: 120,
|
|
41
|
+
}],
|
|
42
|
+
}] };
|
|
43
|
+
const quote = commerce.quote(commerceDocument, {
|
|
44
|
+
carrier_id: 'acme', service_id: 'ground', tariff_version: 1,
|
|
45
|
+
zone: 'zone-a', actual_weight_g: 1200, volume_mm3: 6000000,
|
|
46
|
+
});
|
|
47
|
+
console.log(quote.quote.total_minor);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quotes, policy and catalog versions
|
|
51
|
+
|
|
52
|
+
`commerce` has three functions, all deterministic and all over one document you supply —
|
|
53
|
+
no clock, no network, no hidden state. A history is a list and a version's number is its
|
|
54
|
+
position in that list starting at 1, so `tariff_version: 2` always means "the second
|
|
55
|
+
entry under this carrier and service".
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
import { commerce } from '@packvium/engine';
|
|
59
|
+
|
|
60
|
+
// Which version applies: pin it, or ask what was in force at an instant. Never both.
|
|
61
|
+
commerce.quote(document, { /* ... */ tariff_version: 1 });
|
|
62
|
+
commerce.quote(document, { /* ... */ as_of: 1500 });
|
|
63
|
+
|
|
64
|
+
// The decision, and the rule id and version that made it.
|
|
65
|
+
const { decision } = commerce.evaluatePolicy(document, {
|
|
66
|
+
scope: 'hazmat', context: { un_class: '1.4' }, as_of: 0,
|
|
67
|
+
});
|
|
68
|
+
decision.allowed; // false
|
|
69
|
+
decision.citation.rule_id; // "no-hazmat-air"
|
|
70
|
+
|
|
71
|
+
// Which catalog version a pin resolves to, what it holds, whether it was a rollback.
|
|
72
|
+
const { catalog } = commerce.catalogVersionInfo(document, {
|
|
73
|
+
catalog_id: 'dc-12', version: 2, resolved_at: 1700,
|
|
74
|
+
});
|
|
75
|
+
catalog.entry_counts; // { items: 1, cartons: 1, pallets: 0, ... }
|
|
76
|
+
catalog.rolled_back_from; // 1, or null for an ordinary publication
|
|
77
|
+
|
|
78
|
+
// Store, log and compare results in the canonical form, not JSON.stringify.
|
|
79
|
+
commerce.canonicalJson(result);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Two kinds of failure, and they are not interchangeable:
|
|
83
|
+
|
|
84
|
+
- a **malformed** document or request is your bug and throws `CommerceInputError`;
|
|
85
|
+
- a request the model simply **cannot answer** — no tariff effective at that instant, no
|
|
86
|
+
rate for that zone — is a successful call returning `"status": "rejected"` with a code
|
|
87
|
+
from a closed set and structured fields naming what was missing.
|
|
88
|
+
|
|
89
|
+
`commerce.backend()` reports whether the native addon or the JavaScript implementation
|
|
90
|
+
answered; both return the same result for the same input. A runnable walk-through of all
|
|
91
|
+
three functions is in [examples/commerce.mjs](examples/commerce.mjs), and the full
|
|
92
|
+
contract — document format, every result shape, all ten rejection codes, complexity and
|
|
93
|
+
limitations — is `docs/COMMERCE-API.md`.
|
|
94
|
+
|
|
95
|
+
## Examples
|
|
96
|
+
|
|
97
|
+
Runnable, in [`examples/`](examples). Each one is a single file you can read top to bottom
|
|
98
|
+
and execute without a project around it.
|
|
99
|
+
|
|
100
|
+
| File | What it shows |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| [`basic.mjs`](examples/basic.mjs) | Pack an order, read placements, and see why an item was refused. |
|
|
103
|
+
| [`commerce.mjs`](examples/commerce.mjs) | Rate a shipment, apply an eligibility rule, and pin a catalog version. |
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
node examples/basic.mjs
|
|
34
107
|
```
|
|
35
108
|
|
|
36
109
|
## Features
|
|
@@ -41,14 +114,33 @@ console.log(result.containers);
|
|
|
41
114
|
- JSON input/output through `pack()` or `packJson()`.
|
|
42
115
|
- Optional payload rebalancing with `rebalanceWeight()`.
|
|
43
116
|
- Loading and removal sequence helpers for already placed boxes.
|
|
117
|
+
- Deterministic carrier quotes, policy evaluation and effective-dated catalog lookup
|
|
118
|
+
through `commerce`.
|
|
44
119
|
|
|
45
120
|
The native addon is optional. `npm install` works on unsupported platforms too; call
|
|
46
121
|
`backend()` if your application needs to know which implementation handled a request.
|
|
47
122
|
|
|
123
|
+
## The Packvium family
|
|
124
|
+
|
|
125
|
+
One request and result contract, implemented independently in four engines (Rust,
|
|
126
|
+
Python, PHP, JavaScript) and held to identical placements on a shared fixture set.
|
|
127
|
+
Pick the package for your stack; mixing them in one system is safe.
|
|
128
|
+
|
|
129
|
+
| Package | Install | Source |
|
|
130
|
+
| --- | --- | --- |
|
|
131
|
+
| Python — [`packvium`](https://pypi.org/project/packvium/) | `pip install packvium` | [packvium-python](https://github.com/toxakara/packvium-python) |
|
|
132
|
+
| PHP — [`packvium/packvium`](https://packagist.org/packages/packvium/packvium) | `composer require packvium/packvium` | [packvium-php](https://github.com/toxakara/packvium-php) |
|
|
133
|
+
| Rust — [`packvium`](https://crates.io/crates/packvium) | `packvium = "0.1"` | [packvium-rust](https://github.com/toxakara/packvium-rust) |
|
|
134
|
+
| Node.js — [`@packvium/engine`](https://www.npmjs.com/package/@packvium/engine) | `npm install @packvium/engine` | [packvium-node](https://github.com/toxakara/packvium-node) |
|
|
135
|
+
| Browser / WebAssembly — [`@packvium/browser`](https://www.npmjs.com/package/@packvium/browser) | `npm install @packvium/browser` | [packvium-wasm](https://github.com/toxakara/packvium-wasm) |
|
|
136
|
+
| PHP FFI bridge — [`packvium/native-bridge`](https://packagist.org/packages/packvium/native-bridge) | `composer require packvium/native-bridge` | [packvium-php-bridge](https://github.com/toxakara/packvium-php-bridge) |
|
|
137
|
+
| Python native selector — `packvium-native` | from source until the native wheels ship | [packvium-python-adapter](https://github.com/toxakara/packvium-python-adapter) |
|
|
138
|
+
|
|
48
139
|
## API and support
|
|
49
140
|
|
|
50
141
|
TypeScript declarations are included. See the package's `index.d.ts` for the complete
|
|
51
|
-
request and result types
|
|
142
|
+
request and result types, and `docs/COMMERCE-API.md` for the commercial/control-plane
|
|
143
|
+
contract. Report security issues through [SECURITY.md](SECURITY.md).
|
|
52
144
|
|
|
53
145
|
## License
|
|
54
146
|
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
Only the latest `0.1.x` release receives fixes. This is an early release; there is no
|
|
6
|
+
long-term support branch yet.
|
|
7
|
+
|
|
8
|
+
## Reporting a vulnerability
|
|
9
|
+
|
|
10
|
+
Please report privately, not in a public issue. Use GitHub's **Report a vulnerability**
|
|
11
|
+
button under the Security tab, which opens a private advisory.
|
|
12
|
+
|
|
13
|
+
Include the version, the platform, a minimal request that reproduces the problem, and
|
|
14
|
+
what you observed. A crash, a hang or a wildly wrong result on a small input is worth
|
|
15
|
+
reporting even if you are unsure it is a security matter.
|
|
16
|
+
|
|
17
|
+
You can expect an acknowledgement within a few working days and an assessment after that.
|
|
18
|
+
Please give us a chance to ship a fix before disclosing publicly.
|
|
19
|
+
|
|
20
|
+
## Threat model
|
|
21
|
+
|
|
22
|
+
This is a computation library. It has **no runtime dependencies**, opens no network
|
|
23
|
+
connections, spawns no processes, reads no files except through the CLI's standard input,
|
|
24
|
+
and executes no user-supplied code except the extension objects you register yourself.
|
|
25
|
+
|
|
26
|
+
The realistic risks are therefore about untrusted **input**:
|
|
27
|
+
|
|
28
|
+
- **Resource exhaustion.** A request with a large item count, many container types or a
|
|
29
|
+
permissive solver profile can consume substantial CPU and memory. If you accept
|
|
30
|
+
requests from untrusted callers, set `time_limit_ms`, cap item quantities and container
|
|
31
|
+
inventory, and run the call where you can bound memory. The deadline is honoured by the
|
|
32
|
+
search, but a single enormous request can still allocate a lot before the first check.
|
|
33
|
+
- **Integer magnitude.** Dimensions are converted to exact integer ticks — 16 000 per
|
|
34
|
+
millimetre. Absurd inputs produce very large integers rather than overflow, but they
|
|
35
|
+
cost time and memory. Validate dimensions against a sane maximum before passing them in.
|
|
36
|
+
- **Malformed input.** Bad units, unparseable numbers and contradictory constraints raise
|
|
37
|
+
errors rather than producing a wrong packing. Do not suppress those errors.
|
|
38
|
+
|
|
39
|
+
A result that reports success has passed independent validation, but validation checks
|
|
40
|
+
the constraints you declared. It cannot know about a constraint you did not express.
|
|
41
|
+
|
|
42
|
+
## Extensions
|
|
43
|
+
|
|
44
|
+
Custom constraints, orderings, scorers, container selectors and solvers run with the
|
|
45
|
+
privileges of your process. Treat a third-party extension as you would any other
|
|
46
|
+
dependency: read it before you register it.
|
|
47
|
+
|
|
48
|
+
## Signing and keyless publishing
|
|
49
|
+
|
|
50
|
+
Every release is built and published from CI, never from a maintainer's workstation,
|
|
51
|
+
so there is one attacker-controlled surface to defend: the release workflow itself.
|
|
52
|
+
|
|
53
|
+
- **Signed tags.** The tag a release is cut from is a signed git tag (`git tag -s`),
|
|
54
|
+
verifiable against the maintainers' published keys. An unsigned tag is not released
|
|
55
|
+
from.
|
|
56
|
+
- **OIDC trusted publishing instead of long-lived tokens, where the registry supports
|
|
57
|
+
it.** PyPI and npm both accept a short-lived token minted from the release workflow's
|
|
58
|
+
GitHub Actions OIDC identity instead of a static API token stored as a secret — there
|
|
59
|
+
is no password to leak because none is issued until the moment of publish, and it is
|
|
60
|
+
scoped to that one run. Packagist resolves packages directly from the tagged git
|
|
61
|
+
repository and has no comparable upload token to eliminate. crates.io does not yet
|
|
62
|
+
support trusted publishing for this ecosystem; until it does, its token is scoped to
|
|
63
|
+
this crate only, stored as a CI secret, and rotated on the schedule below regardless
|
|
64
|
+
of whether compromise is suspected.
|
|
65
|
+
- **Provenance attestations** (see the release process)
|
|
66
|
+
bind each published artifact back to the exact workflow run, commit and tag that
|
|
67
|
+
produced it, independent of which upload method the registry used.
|
|
68
|
+
- **Recovery.** If the release workflow, its OIDC trust configuration or the crates.io
|
|
69
|
+
token is suspected compromised: revoke the trust relationship (or rotate the token)
|
|
70
|
+
immediately, audit the workflow's recent runs and any artifacts they published,
|
|
71
|
+
and re-cut the release from a clean, re-reviewed commit under a new version — never
|
|
72
|
+
by force-pushing or reusing the affected tag.
|
|
73
|
+
- **Revocation.** A compromised or defective published version is pulled from
|
|
74
|
+
circulation using each registry's own mechanism (PyPI yank, npm deprecate, Packagist
|
|
75
|
+
abandon, crates.io yank) rather than deleted outright, so that projects already
|
|
76
|
+
pinned to it get a clear signal instead of a broken install. The security advisory
|
|
77
|
+
for the issue names the affected versions explicitly.
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commercial and control-plane models: carrier rating, eligibility policy and
|
|
3
|
+
* catalog versioning.
|
|
4
|
+
*
|
|
5
|
+
* An independent implementation of the contract in docs/COMMERCE-API.md, held to
|
|
6
|
+
* producing a valid result that meets each shared fixture's objective floor. For a
|
|
7
|
+
* quote that floor is an exact integer price, so matching it means matching exactly.
|
|
8
|
+
*
|
|
9
|
+
* Money, weight and volume arithmetic runs in BigInt and every inexact division rounds
|
|
10
|
+
* up, so a quote can neither drift through a double nor land a minor unit below what
|
|
11
|
+
* the tariff charges. Results are converted back to Number at the boundary; a component
|
|
12
|
+
* beyond Number.MAX_SAFE_INTEGER is refused rather than silently rounded.
|
|
13
|
+
*
|
|
14
|
+
* This module is package-internal: package.json exports only the root entry point.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export class CommerceInputError extends Error {
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'CommerceInputError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Exact ceil(a * b / d) for non-negative inputs, in BigInt so nothing can wrap. */
|
|
25
|
+
export function ceilMulDiv(a, b, d) {
|
|
26
|
+
const divisor = BigInt(d);
|
|
27
|
+
if (divisor <= 0n) throw new CommerceInputError('divisor must be positive');
|
|
28
|
+
const product = BigInt(a) * BigInt(b);
|
|
29
|
+
return (product + divisor - 1n) / divisor;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Convert an exact BigInt back to a JSON number, refusing a value a double cannot hold. */
|
|
33
|
+
export function exact(value) {
|
|
34
|
+
const number = Number(value);
|
|
35
|
+
if (!Number.isSafeInteger(number)) {
|
|
36
|
+
throw new CommerceInputError(
|
|
37
|
+
`${value} is outside JavaScript's exact integer range; this quote cannot be represented`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Order two strings by Unicode code point, the way every other implementation orders
|
|
45
|
+
* them.
|
|
46
|
+
*
|
|
47
|
+
* JavaScript's default string comparison is by UTF-16 code unit, which disagrees with
|
|
48
|
+
* Python, PHP and Rust for any character outside the Basic Multilingual Plane: an emoji
|
|
49
|
+
* (U+1F600) sorts *before* a fullwidth Latin A (U+FF21) by code unit and *after* it by
|
|
50
|
+
* code point. Sorted id lists are part of this contract's answer, so a default `.sort()`
|
|
51
|
+
* would make this implementation disagree with the other three on exactly those inputs.
|
|
52
|
+
*/
|
|
53
|
+
export function compareCodePoints(left, right) {
|
|
54
|
+
const a = Array.from(left);
|
|
55
|
+
const b = Array.from(right);
|
|
56
|
+
const shared = Math.min(a.length, b.length);
|
|
57
|
+
for (let index = 0; index < shared; index += 1) {
|
|
58
|
+
const difference = a[index].codePointAt(0) - b[index].codePointAt(0);
|
|
59
|
+
if (difference !== 0) return difference < 0 ? -1 : 1;
|
|
60
|
+
}
|
|
61
|
+
if (a.length === b.length) return 0;
|
|
62
|
+
return a.length < b.length ? -1 : 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ------------------------------------------------------------------------------ rating
|
|
66
|
+
|
|
67
|
+
/** The charge one accessorial adds, given the base charge it may be a permille of. */
|
|
68
|
+
export function accessorialCharge(accessorial, baseChargeMinor) {
|
|
69
|
+
if (accessorial.flatChargeMinor !== null) return BigInt(accessorial.flatChargeMinor);
|
|
70
|
+
return ceilMulDiv(baseChargeMinor, accessorial.permilleOfBase, 1000);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Rate a request against one already-resolved immutable tariff version.
|
|
75
|
+
*
|
|
76
|
+
* Returns either `{breakdown}` or `{rejection}`, where a rejection names structurally
|
|
77
|
+
* what was missing -- never a silently-zero charge.
|
|
78
|
+
*/
|
|
79
|
+
export function rateTariff(tariff, request) {
|
|
80
|
+
if (!Object.hasOwn(tariff.costPerDimensionalKgMinor, request.zone)) {
|
|
81
|
+
return { rejection: { kind: 'zone', zone: request.zone } };
|
|
82
|
+
}
|
|
83
|
+
const unknown = request.requestedAccessorials
|
|
84
|
+
.filter((id) => !Object.hasOwn(tariff.accessorials, id))
|
|
85
|
+
.sort(compareCodePoints);
|
|
86
|
+
if (unknown.length > 0) return { rejection: { kind: 'accessorial', accessorialIds: unknown } };
|
|
87
|
+
|
|
88
|
+
// Dimensional weight in grams is volume (mm^3) over the divisor, rounded up.
|
|
89
|
+
const dimensionalWeightG = ceilMulDiv(request.volumeMm3, 1, tariff.dimensionalWeightDivisor);
|
|
90
|
+
const billedWeightG =
|
|
91
|
+
BigInt(request.actualWeightG) > dimensionalWeightG
|
|
92
|
+
? BigInt(request.actualWeightG)
|
|
93
|
+
: dimensionalWeightG;
|
|
94
|
+
|
|
95
|
+
const rawBaseChargeMinor = ceilMulDiv(
|
|
96
|
+
billedWeightG, tariff.costPerDimensionalKgMinor[request.zone], 1000,
|
|
97
|
+
);
|
|
98
|
+
const minimumChargeApplied = rawBaseChargeMinor < BigInt(tariff.minimumChargeMinor);
|
|
99
|
+
const baseChargeMinor = minimumChargeApplied
|
|
100
|
+
? BigInt(tariff.minimumChargeMinor)
|
|
101
|
+
: rawBaseChargeMinor;
|
|
102
|
+
|
|
103
|
+
const fuelSurchargeMinor = ceilMulDiv(baseChargeMinor, tariff.fuelSurchargePermille, 1000);
|
|
104
|
+
const accessorialCharges = request.requestedAccessorials.map((id) => [
|
|
105
|
+
id, accessorialCharge(tariff.accessorials[id], baseChargeMinor),
|
|
106
|
+
]);
|
|
107
|
+
const accessorialTotal = accessorialCharges.reduce((sum, [, amount]) => sum + amount, 0n);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
breakdown: {
|
|
111
|
+
carrier_id: tariff.carrierId,
|
|
112
|
+
service_id: tariff.serviceId,
|
|
113
|
+
tariff_version: tariff.version,
|
|
114
|
+
zone: request.zone,
|
|
115
|
+
actual_weight_g: request.actualWeightG,
|
|
116
|
+
dimensional_weight_g: exact(dimensionalWeightG),
|
|
117
|
+
billed_weight_g: exact(billedWeightG),
|
|
118
|
+
base_charge_minor: exact(baseChargeMinor),
|
|
119
|
+
minimum_charge_applied: minimumChargeApplied,
|
|
120
|
+
fuel_surcharge_minor: exact(fuelSurchargeMinor),
|
|
121
|
+
accessorial_charges_minor: accessorialCharges.map(([id, amount]) => [id, exact(amount)]),
|
|
122
|
+
total_minor: exact(baseChargeMinor + fuelSurchargeMinor + accessorialTotal),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The version effective at `asOf`: the highest `effective_at` not after it, ties broken
|
|
129
|
+
* by the higher (later-published) version number. Null when nothing has taken effect.
|
|
130
|
+
*/
|
|
131
|
+
export function effectiveVersion(history, asOf, numberOf) {
|
|
132
|
+
let winner = null;
|
|
133
|
+
for (const candidate of history) {
|
|
134
|
+
if (candidate.effectiveAt > asOf) continue;
|
|
135
|
+
if (
|
|
136
|
+
winner === null
|
|
137
|
+
|| candidate.effectiveAt > winner.effectiveAt
|
|
138
|
+
|| (candidate.effectiveAt === winner.effectiveAt && numberOf(candidate) > numberOf(winner))
|
|
139
|
+
) {
|
|
140
|
+
winner = candidate;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return winner;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ------------------------------------------------------------------------------ policy
|
|
147
|
+
|
|
148
|
+
export const POLICY_SCOPES = [
|
|
149
|
+
'facility', 'customer', 'carrier', 'material', 'hazmat', 'temperature', 'service',
|
|
150
|
+
];
|
|
151
|
+
export const POLICY_OPERATORS = ['equals', 'not_equals', 'in', 'not_in', 'exists', 'absent'];
|
|
152
|
+
export const POLICY_ACTIONS = ['allow', 'reject'];
|
|
153
|
+
const UNARY_OPERATORS = ['exists', 'absent'];
|
|
154
|
+
|
|
155
|
+
export function isUnary(operator) {
|
|
156
|
+
return UNARY_OPERATORS.includes(operator);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Value equality over the JSON scalar types, matching the reference implementation
|
|
161
|
+
* exactly -- including that a boolean equals the integer it stands for, the one place a
|
|
162
|
+
* naive `===` would disagree and quietly change a decision.
|
|
163
|
+
*/
|
|
164
|
+
export function valuesEqual(left, right) {
|
|
165
|
+
if (typeof left === 'boolean' || typeof right === 'boolean') {
|
|
166
|
+
if (typeof left === 'boolean' && typeof right === 'boolean') return left === right;
|
|
167
|
+
const other = typeof left === 'boolean' ? right : left;
|
|
168
|
+
return typeof other === 'number' && Number(left) === Number(right);
|
|
169
|
+
}
|
|
170
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
171
|
+
return left.length === right.length && left.every((entry, index) => valuesEqual(entry, right[index]));
|
|
172
|
+
}
|
|
173
|
+
if (Array.isArray(left) || Array.isArray(right)) return false;
|
|
174
|
+
if (left === null || right === null) return left === right;
|
|
175
|
+
if (typeof left !== typeof right) return false;
|
|
176
|
+
return left === right;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function contains(haystack, needle) {
|
|
180
|
+
if (Array.isArray(haystack)) return haystack.some((entry) => valuesEqual(entry, needle));
|
|
181
|
+
if (typeof haystack === 'string') return typeof needle === 'string' && haystack.includes(needle);
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function predicateMatches(predicate, context) {
|
|
186
|
+
const present = Object.hasOwn(context, predicate.field);
|
|
187
|
+
if (predicate.operator === 'exists') return present;
|
|
188
|
+
if (predicate.operator === 'absent') return !present;
|
|
189
|
+
if (!present) return false;
|
|
190
|
+
const actual = context[predicate.field];
|
|
191
|
+
switch (predicate.operator) {
|
|
192
|
+
case 'equals': return valuesEqual(actual, predicate.value);
|
|
193
|
+
case 'not_equals': return !valuesEqual(actual, predicate.value);
|
|
194
|
+
case 'in': return contains(predicate.value, actual);
|
|
195
|
+
default: return !contains(predicate.value, actual);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function ruleMatches(rule, context) {
|
|
200
|
+
return rule.predicates.every((predicate) => predicateMatches(predicate, context));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Evaluate an already-resolved immutable rule set.
|
|
205
|
+
*
|
|
206
|
+
* Deny takes precedence: an explicit REJECT always outranks an ALLOW for the same
|
|
207
|
+
* context. Among equals the highest priority wins, ties break on the lexicographically
|
|
208
|
+
* smallest rule id, and nothing matching at all is allowed with no citation.
|
|
209
|
+
*/
|
|
210
|
+
export function decide(rules, scope, context) {
|
|
211
|
+
const matching = rules.filter((rule) => rule.scope === scope && ruleMatches(rule, context));
|
|
212
|
+
const rejects = matching.filter((rule) => rule.action === 'reject');
|
|
213
|
+
const pool = rejects.length > 0 ? rejects : matching;
|
|
214
|
+
if (pool.length === 0) return { scope, allowed: true, citation: null };
|
|
215
|
+
|
|
216
|
+
// Ties break on the lexicographically smallest rule id -- by code point, because a
|
|
217
|
+
// default `<` on strings compares UTF-16 code units and would cite a different rule
|
|
218
|
+
// than the other three implementations whenever an id leaves the Basic Multilingual
|
|
219
|
+
// Plane.
|
|
220
|
+
const winner = pool.reduce((best, rule) => (
|
|
221
|
+
rule.priority > best.priority
|
|
222
|
+
|| (rule.priority === best.priority && compareCodePoints(rule.ruleId, best.ruleId) < 0)
|
|
223
|
+
? rule : best
|
|
224
|
+
));
|
|
225
|
+
return {
|
|
226
|
+
scope,
|
|
227
|
+
allowed: winner.action === 'allow',
|
|
228
|
+
citation: {
|
|
229
|
+
rule_id: winner.ruleId,
|
|
230
|
+
version: winner.version,
|
|
231
|
+
action: winner.action,
|
|
232
|
+
priority: winner.priority,
|
|
233
|
+
reason: winner.reason,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|