wayleave 0.5.0 → 0.5.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.
Files changed (4) hide show
  1. package/README.md +8 -10
  2. package/index.js +58 -4
  3. package/package.json +2 -2
  4. package/policy.js +30 -1
package/README.md CHANGED
@@ -32,7 +32,7 @@ signature headers and get back who signed it and which lane it belongs in.
32
32
  Nothing is stored, and your traffic never routes through us.
33
33
 
34
34
  ```sh
35
- curl -X POST https://wayleave-api-production.up.railway.app/v1/identify \
35
+ curl -X POST https://api.wayleave.dev/v1/identify \
36
36
  -H "Authorization: Bearer $WAYLEAVE_API_KEY" \
37
37
  -H "Content-Type: application/json" \
38
38
  -d '{"method":"GET","url":"https://api.yourapp.com/v1/data",
@@ -48,8 +48,7 @@ curl -X POST https://wayleave-api-production.up.railway.app/v1/identify \
48
48
 
49
49
  The same evaluator answers here and in the middleware, and a test asserts they
50
50
  agree — if the two front doors disagreed about one request you would have no
51
- way to know which to believe. (`api.wayleave.dev` is the intended hostname and
52
- has no DNS record yet; the URL above is the one that answers today.)
51
+ way to know which to believe.
53
52
 
54
53
  **2. The middleware — in your own process.** Nothing leaves your server that
55
54
  you do not send. This is the path if you want to price routes, enforce access
@@ -63,8 +62,10 @@ npm install wayleave
63
62
 
64
63
  [Create a Meter account](https://meter.wayleave.dev/account.html), issue an API key,
65
64
  and set `WAYLEAVE_METER_KEY` in your server environment. Signing in does not
66
- start billing. The optional hosted Meter trial requires a card and becomes
67
- $49/month after 3 days unless canceled; see your account for current terms.
65
+ start billing. The optional hosted plans require a card and start after a
66
+ 3-day trial unless canceled — current plans and prices are at
67
+ [wayleave.dev/pricing](https://wayleave.dev/pricing/), which is the one place
68
+ they are kept up to date.
68
69
 
69
70
  In your existing Express app:
70
71
 
@@ -122,10 +123,7 @@ rather than quietly denying every payment.
122
123
 
123
124
  ## Enforce rules you set elsewhere
124
125
 
125
- > Requires 0.5.0. npm currently serves **0.4.1**, which has no `policy` option —
126
- > installing from npm and pasting this will silently do nothing. Until 0.5.0 is
127
- > published, install from source:
128
- > `npm install github:gibrancorbin11-hub/wayleave`
126
+ > Requires 0.5.0 or later, which is what `npm install wayleave` now gives you.
129
127
 
130
128
  A policy the gate fetches, rather than configuration compiled into your app:
131
129
 
@@ -192,7 +190,7 @@ that tooling in the wild does fetch.
192
190
 
193
191
  ## Status
194
192
 
195
- v0.5.0 — 155 tests, zero dependencies. Published on npm: 0.4.1.
193
+ v0.5.0 — 155 tests, zero dependencies. Published on npm.
196
194
 
197
195
  **0.5.0** does three things. Rules you set are now *enforced*: `policy: { url, publicKey }` fetches a
198
196
  signed policy, verifies it, caches it, and evaluates per request — denials carry the rule id in
package/index.js CHANGED
@@ -95,6 +95,31 @@ function sigBase(components, ctx, paramsRaw) {
95
95
  return Buffer.from(lines.join('\n'));
96
96
  }
97
97
 
98
+ /**
99
+ * Does `path` fall under `prefix`?
100
+ *
101
+ * Not `startsWith`. A raw prefix test makes "/api" match "/apiv2" and
102
+ * "/api-internal", which is wrong in both directions and dangerous in one:
103
+ *
104
+ * pricedPaths { "/api": 0.05 } charges for /apiv2, which the operator
105
+ * never priced
106
+ * rules { bot: [["/public", true]] } ALLOWS /public-admin, which the
107
+ * operator never opened
108
+ *
109
+ * The second is the one that matters. An over-matching deny is merely
110
+ * annoying; an over-matching allow is a hole.
111
+ *
112
+ * A prefix matches the path itself or anything beneath its boundary, and a
113
+ * trailing slash on the prefix changes nothing — "/api" and "/api/" mean the
114
+ * same thing, because operators write both and neither should surprise them.
115
+ */
116
+ export function pathUnder(path, prefix) {
117
+ if (typeof path !== 'string' || typeof prefix !== 'string') return false;
118
+ if (prefix === '' || prefix === '/') return true;
119
+ const base = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
120
+ return path === base || path.startsWith(base + '/');
121
+ }
122
+
98
123
  export function buildParams(keyid, created, expires, nonce = null) {
99
124
  return `("@authority" "signature-agent");created=${created};` +
100
125
  `keyid="${keyid}";alg="ed25519";expires=${expires};tag="web-bot-auth"` +
@@ -631,7 +656,7 @@ export class Wayleave {
631
656
  */
632
657
  _preDecide(req, lane, ident, now, paymentProof, c) {
633
658
  for (const [prefix, allow] of this.rules[lane] || []) {
634
- if (req.path.startsWith(prefix)) {
659
+ if (pathUnder(req.path, prefix)) {
635
660
  if (!allow) return { status: 403, why: `${lane} denied on ${prefix}` };
636
661
  break;
637
662
  }
@@ -659,7 +684,7 @@ export class Wayleave {
659
684
  : lane === LANES.HUMAN;
660
685
  if (!freeOnPriced) {
661
686
  for (const [prefix, price] of Object.entries(this.pricedPaths)) {
662
- if (req.path.startsWith(prefix)) {
687
+ if (pathUnder(req.path, prefix)) {
663
688
  const challenge = { scheme: 'x402', price_usd: price, resource: prefix };
664
689
  if (!paymentProof) {
665
690
  const why = this.strictPricedPaths && lane === LANES.HUMAN
@@ -802,6 +827,20 @@ export class Wayleave {
802
827
 
803
828
  express() {
804
829
  return async (req, res, next) => {
830
+ /* The discovery document is answered first: before classification,
831
+ before rate limiting, before pricing. A manifest an agent must pay
832
+ to read cannot be discovered, and one that is classified as bot
833
+ traffic gets a 402 instead of the price list.
834
+
835
+ This lived only on manifestFor() until 0.5.1, so `app.use(gate.express())`
836
+ -- the integration the Quickstart shows -- served no manifest at all
837
+ while the README said every install with a priced route does. */
838
+ const manifest = this.manifestFor({
839
+ path: req.path || (req.url || '').split('?')[0],
840
+ headers: req.headers,
841
+ });
842
+ if (manifest) return res.status(manifest.status).set(manifest.headers).json(manifest.body);
843
+
805
844
  const r = await this.handleAsync({
806
845
  method: req.method, path: req.path || req.url,
807
846
  authority: req.headers.host || '',
@@ -816,10 +855,25 @@ export class Wayleave {
816
855
  }, undefined, req.headers['x-payment-proof'] || '');
817
856
  req.wayleave = r;
818
857
  if (r.status === 200) return next();
819
- if (r.status === 402)
858
+ if (r.status === 402) {
859
+ /* The 402 body and the manifest must come from one source, or an
860
+ index quotes a price the origin does not honour. `accepts` is
861
+ built by the same call the manifest is, so they cannot drift.
862
+
863
+ Without a payment config there is nothing spec-shaped to say --
864
+ no payee, no network, no asset -- so the internal challenge is
865
+ all that is sent, and `accepts` is absent rather than empty.
866
+ An empty accepts array would read as "priced at nothing". */
867
+ const body = { error: r.why, challenge: r.challenge };
868
+ const requirements = this.paymentRequirements({
869
+ path: req.path || (req.url || '').split('?')[0],
870
+ headers: req.headers,
871
+ });
872
+ if (requirements) { body.x402Version = 1; body.accepts = [requirements]; }
820
873
  return res.status(402)
821
874
  .set('x-payment-challenge', JSON.stringify(r.challenge))
822
- .json({ error: r.why, challenge: r.challenge });
875
+ .json(body);
876
+ }
823
877
  return res.status(r.status).json({ error: r.why });
824
878
  };
825
879
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "wayleave",
3
- "version": "0.5.0",
4
- "description": "Know which AI agents cross your API — verified by signature, not guessed from an IP. Sorts every request into four lanes, prices the non-human ones with HTTP 402, and never charges a human. Zero dependencies.",
3
+ "version": "0.5.2",
4
+ "description": "Know which AI agents cross your API \u2014 verified by signature, not guessed from an IP. Sorts every request into four lanes, prices the non-human ones with HTTP 402, and never charges a human. Zero dependencies.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "types": "./index.d.ts",
package/policy.js CHANGED
@@ -114,9 +114,33 @@ export class RemotePolicy {
114
114
  * @param {(e:object)=>void} [o.onEvent] observability; never throws into us
115
115
  */
116
116
  constructor({ apiKey = null, url = null, publicKey = null, endpoint = DEFAULT_ENDPOINT,
117
- refreshMs = DEFAULT_REFRESH_MS, graceMs = 0,
117
+ refreshMs = DEFAULT_REFRESH_MS, graceMs = 0, allowUnsigned = false,
118
118
  cachePath = null, onEvent = null, onChange = null, fetchImpl = null } = {}) {
119
119
  if (!apiKey && !url) throw new Error('RemotePolicy requires a policy url (or the meter apiKey)');
120
+ /* A policy document decides who is allowed through and what they are
121
+ charged. Applying one nobody signed means whoever can answer that URL
122
+ — a hijacked CDN, a DNS answer, anyone on the path — writes your access
123
+ rules. Until 0.5.2 a missing publicKey silently skipped the check.
124
+
125
+ The requirement applies to a bare `url` only. With `apiKey` the document
126
+ comes from the customer's own meter over TLS with their key, which is an
127
+ authenticated channel; the signature is defence in depth there, not the
128
+ only defence. A public policy URL has no such channel — being publicly
129
+ cacheable is the point of it — so the signature IS the authentication.
130
+
131
+ Refusing at construction rather than at fetch time is deliberate: the
132
+ failure belongs at startup, where someone is watching, not on a poll at
133
+ three in the morning. `allowUnsigned: true` remains for a URL you trust
134
+ end to end, and it has to be written down. */
135
+ if (url && !apiKey && !publicKey && !allowUnsigned)
136
+ throw new Error(
137
+ 'RemotePolicy requires publicKey: an unsigned policy document is an ' +
138
+ 'unauthenticated one, and it decides access and pricing. Pass the ' +
139
+ 'signing key, or allowUnsigned: true if you genuinely trust this URL.');
140
+ this.allowUnsigned = allowUnsigned;
141
+ // True only for a bare public URL, where the signature is the only
142
+ // thing authenticating the document.
143
+ this._needsSignature = Boolean(url) && !apiKey;
120
144
  this.apiKey = apiKey;
121
145
  this.publicKey = publicKey;
122
146
  this.graceMs = graceMs;
@@ -186,6 +210,11 @@ export class RemotePolicy {
186
210
  const v = verifyEnvelope(raw, this.publicKey, { graceMs: this.graceMs });
187
211
  if (!v.ok) { this._emit('policy.rejected', { why: v.reason }); return this.document; }
188
212
  doc = v.policy;
213
+ } else if (this._needsSignature && !this.allowUnsigned) {
214
+ // Unreachable via the constructor, which refuses this combination.
215
+ // Kept because the field is public and a caller may clear it.
216
+ this._emit('policy.rejected', { why: 'no signing key configured' });
217
+ return this.document;
189
218
  }
190
219
  if (!isUsable(doc)) { this._emit('policy.rejected', { why: 'document not usable' }); return this.document; }
191
220