@north-light/plugin 0.3.487 → 0.3.489
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 +4 -2
- package/dist/define.d.ts +9 -3
- package/dist/index.d.ts +1 -1
- package/dist/receipts.d.ts +25 -8
- package/dist/receipts.js +1 -1
- package/dist/serve.js +3 -3
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -124,9 +124,11 @@ On Node, Express or Nest, mount the Fetch handler with `createNodeHandler(handle
|
|
|
124
124
|
|
|
125
125
|
## Metered provider tools
|
|
126
126
|
|
|
127
|
-
With directory authentication, configure `createFetchHandler(plugin, { auth, receipts: { provider: 'app:acme',
|
|
127
|
+
With directory authentication, configure `createFetchHandler(plugin, { auth, receipts: { provider: 'app:acme', outbox } })`. `provider` must be the provider's authenticated `app:<id>` principal, not the plugin name. Emitted receipt `tool` values, and the `tool` that `ctx.checkSpend()` sends, are command paths **after** the plugin name: `send` for `crtr acme send`, or `messages send` for `crtr acme messages send`. A handler calls `ctx.meter({ units, unit })` to emit a `Crtr-Receipt`; the provider persists and posts that same receipt to the directory ledger.
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
The kit holds no prices: the directory owns every price, maximum and allowance (set in the console or with `crtr cloud apps provider pricing`), and charges from the receipt's `units` or its reported cost. `ctx.meter` sets the receipt's `usd_equivalent` to the first of: the `usdEquivalent` you pass (a tool that reports its cost passes it); `units` × the unit price that this request's `ctx.checkSpend()` answered, for a unit-priced tool; otherwise `0`. It never throws for a missing price. The figure is what the runtime shows as the call's cost; the directory recomputes the charge itself and never trusts it. `ReceiptOptions.rates` no longer exists: delete it when you upgrade (TypeScript flags it; a JavaScript config that still passes it is ignored).
|
|
130
|
+
|
|
131
|
+
`ctx.caller.payer` is the principal charged for the call, `user:<id>` or `app:<id>`: the token's `payer` claim, or `sub` when the token has none. `ctx.meter` stamps it on the receipt. Before a priced call, `await ctx.checkSpend()` asks the directory (`POST <auth.issuer>/v1/billing/check`, sending the request's outbound token) whether that payer may spend on this command (it posts `{ token, tool }`, `tool` being the command path above) and keeps the price it answers for `ctx.meter`; it reserves nothing. It needs `receipts.ledger: { clientId, privateKey, keyId? }`, the same credentials you pass to `postReceipt`, and throws a plain `Error` without them or without a verified caller. It resolves when the call may go ahead, throws `SpendRefusedError { code, message, next }` on a refusal (`payment_required` or `limit_reached`, with `next` the page that fixes it), and throws `ReceiptPostError` when the check cannot be completed. To refuse the call, rethrow it as `new LeafError({ code: e.code, message: e.message, status: 402, next: e.next })`.
|
|
130
132
|
|
|
131
133
|
To answer a rate limit, throw a `LeafError` with `retryAfter` (seconds, or a `Date`): `throw new LeafError({ code: 'rate_limited', status: 429, message: 'Slow down.', retryAfter: 30 })` answers 429 with `Retry-After: 30`. `headers: { 'X-RateLimit-Reset': '…' }` sets any other response header; `Content-Type`, `Content-Length`, `Content-Encoding`, `Transfer-Encoding` and `Crtr-Receipt` belong to the kit. For a side-effect leaf the kit passes the headers to `requests.store` as `answer.headers` (lowercase names); a store that keeps and returns them makes a repeated request answer with the same headers.
|
|
132
134
|
|
package/dist/define.d.ts
CHANGED
|
@@ -47,15 +47,21 @@ export interface LeafContext {
|
|
|
47
47
|
readonly approval: {
|
|
48
48
|
require(question: LeafErrorQuestion): Promise<string>;
|
|
49
49
|
};
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Records the cost of this call once, including when the leaf later fails. The receipt's
|
|
52
|
+
* `usd_equivalent` is `usdEquivalent` when given; otherwise `units` × the unit price that this
|
|
53
|
+
* request's `checkSpend()` answered, for a unit-priced tool; otherwise 0. The directory owns every
|
|
54
|
+
* price and recomputes the charge itself, so this figure is the cost shown to the runtime only.
|
|
55
|
+
*/
|
|
51
56
|
readonly meter: (usage: {
|
|
52
57
|
units: number;
|
|
53
58
|
unit: string;
|
|
54
59
|
usdEquivalent?: number;
|
|
55
60
|
}) => Receipt;
|
|
56
61
|
/**
|
|
57
|
-
* Asks the directory, before a priced call, whether `caller.payer` may spend
|
|
58
|
-
*
|
|
62
|
+
* Asks the directory, before a priced call, whether `caller.payer` may spend on this command
|
|
63
|
+
* (it sends the command path after the plugin name as `tool`). Resolves when it may and
|
|
64
|
+
* reserves nothing; keeps the price the directory answered for `ctx.meter`. Throws `SpendRefusedError` when the directory refuses (HTTP 402) and
|
|
59
65
|
* `ReceiptPostError` when the check cannot be completed. Throws an `Error` when the leaf has no
|
|
60
66
|
* verified caller or the handler has no `receipts` (with `receipts.ledger`).
|
|
61
67
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -7,5 +7,5 @@ export * from './bundle.js';
|
|
|
7
7
|
export * from './serve.js';
|
|
8
8
|
export { createNodeHandler, type NodeHandlerOptions, type NodeLikeRequest, type NodeLikeResponse } from './node.js';
|
|
9
9
|
export { createCallerVerifier, type CallerVerifier, type FetchHandlerAuth } from './verify.js';
|
|
10
|
-
export { postReceipt, ReceiptPostError, SpendRefusedError, type LedgerCredentials, type Receipt, type ReceiptOptions, type ReceiptOutbox } from './receipts.js';
|
|
10
|
+
export { postReceipt, ReceiptPostError, SpendRefusedError, type LedgerCredentials, type Receipt, type ReceiptOptions, type ReceiptOutbox, type SpendCheckAnswer, type SpendPrice } from './receipts.js';
|
|
11
11
|
export { LeafError, type LeafErrorOptions, type LeafErrorQuestion } from './errors.js';
|
package/dist/receipts.d.ts
CHANGED
|
@@ -25,10 +25,6 @@ export interface LedgerCredentials {
|
|
|
25
25
|
export interface ReceiptOptions {
|
|
26
26
|
/** The provider's authenticated principal (e.g. `app:sms`), not its plugin name. */
|
|
27
27
|
readonly provider: string;
|
|
28
|
-
/** Prices keyed by tool path after the plugin name (e.g. `send`). */
|
|
29
|
-
readonly rates: Readonly<Record<string, {
|
|
30
|
-
readonly usdPerUnit: number;
|
|
31
|
-
}>>;
|
|
32
28
|
readonly outbox: ReceiptOutbox;
|
|
33
29
|
/**
|
|
34
30
|
* The provider's directory client credentials. Needed only for `ctx.checkSpend()`, which asks
|
|
@@ -36,6 +32,25 @@ export interface ReceiptOptions {
|
|
|
36
32
|
*/
|
|
37
33
|
readonly ledger?: LedgerCredentials;
|
|
38
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* The directory's current price for the checked tool's group, carried by an `ok` check answer.
|
|
37
|
+
* The directory owns every price: the kit only uses this to show a unit-mode cost on the receipt.
|
|
38
|
+
*/
|
|
39
|
+
export interface SpendPrice {
|
|
40
|
+
readonly mode: 'unit' | 'reported';
|
|
41
|
+
/** The unit name in unit mode, else null. */
|
|
42
|
+
readonly unit: string | null;
|
|
43
|
+
/** The price of one unit in unit mode, else null. */
|
|
44
|
+
readonly usd_per_unit: number | null;
|
|
45
|
+
/** The most one call is charged. */
|
|
46
|
+
readonly max_usd_per_call: number;
|
|
47
|
+
}
|
|
48
|
+
/** The directory's answer to a spend check that allowed the call. */
|
|
49
|
+
export interface SpendCheckAnswer {
|
|
50
|
+
readonly status: string;
|
|
51
|
+
/** Present when the tool's group is priced. */
|
|
52
|
+
readonly price?: SpendPrice;
|
|
53
|
+
}
|
|
39
54
|
export declare class ReceiptPostError extends Error {
|
|
40
55
|
readonly permanent: boolean;
|
|
41
56
|
readonly status?: number | undefined;
|
|
@@ -59,8 +74,10 @@ export declare function postReceipt(receipt: Receipt, ledger: {
|
|
|
59
74
|
keyId?: string;
|
|
60
75
|
}): Promise<void>;
|
|
61
76
|
/**
|
|
62
|
-
* Asks the directory whether the caller whose outbound token this is may start a
|
|
63
|
-
*
|
|
64
|
-
*
|
|
77
|
+
* Asks the directory whether the caller whose outbound token this is may start a call to `tool`
|
|
78
|
+
* (the command path after the plugin name, e.g. `search`). Resolves with the answer on 200 (its
|
|
79
|
+
* `price` when the tool's group is priced), throws `SpendRefusedError` on 402 and `ReceiptPostError`
|
|
80
|
+
* on any other failure. The directory reserves nothing; the raw `token` goes to
|
|
81
|
+
* `<issuer>/v1/billing/check` and nowhere else.
|
|
65
82
|
*/
|
|
66
|
-
export declare function requestSpendCheck(issuer: string, token: string, ledger: LedgerCredentials): Promise<
|
|
83
|
+
export declare function requestSpendCheck(issuer: string, token: string, tool: string, ledger: LedgerCredentials): Promise<SpendCheckAnswer>;
|
package/dist/receipts.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var b=Object.defineProperty;var
|
|
1
|
+
var b=Object.defineProperty;var i=(t,n)=>b(t,"name",{value:n,configurable:!0});class c extends Error{static{i(this,"ReceiptPostError")}permanent;status;constructor(n,r,e){super(n),this.permanent=r,this.status=e,this.name="ReceiptPostError"}}class A extends Error{static{i(this,"SpendRefusedError")}code;next;constructor(n,r,e){super(r),this.code=n,this.next=e,this.name="SpendRefusedError"}}async function C(t,n){const r=await m(n);let e;try{e=await fetch(n.url,{method:"POST",headers:{Authorization:`Bearer ${r}`,"Content-Type":"application/json"},body:JSON.stringify(t)})}catch(o){throw new c(`Could not post receipt: ${String(o)}`,!1)}if(!e.ok)throw new c(`Ledger receipt request failed: HTTP ${e.status}`,l(e.status),e.status)}i(C,"postReceipt");async function m(t,n){const r=new URL("/token",t.url),e=await T(t,r.href);let o;try{o=await fetch(r,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({grant_type:"client_credentials",client_id:t.clientId,client_assertion_type:"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",client_assertion:e}),...n===void 0?{}:{signal:n}})}catch(a){throw new c(`Could not obtain a ledger token: ${String(a)}`,!1)}if(!o.ok)throw new c(`Ledger token request failed: HTTP ${o.status}`,l(o.status),o.status);let u;try{u=(await o.json()).access_token}catch{throw new c("Ledger token response was not JSON",!1)}if(typeof u!="string"||u.length===0)throw new c("Ledger token response has no access_token",!1);return u}i(m,"ledgerToken");async function $(t,n,r,e){const o=AbortSignal.timeout(1e4),u=await m({...e,url:t},o);let a;try{a=await fetch(`${t.replace(/\/+$/,"")}/v1/billing/check`,{method:"POST",headers:{Authorization:`Bearer ${u}`,"Content-Type":"application/json"},body:JSON.stringify({token:n,tool:r}),signal:o})}catch(s){throw new c(`Could not reach the billing check: ${String(s)}`,!1)}if(a.ok)return x(await a.json().catch(()=>{}));if(a.status===402){let s;try{s=(await a.json()).error}catch{}throw typeof s?.code=="string"&&typeof s.message=="string"?new A(s.code,s.message,typeof s.details?.next=="string"?s.details.next:""):new c("Billing check answered 402 without a code and message",!0,402)}throw new c(`Billing check request failed: HTTP ${a.status}`,l(a.status),a.status)}i($,"requestSpendCheck");function x(t){const n=typeof t=="object"&&t!==null?t:{},r=typeof n.status=="string"?n.status:"ok",e=n.price;return typeof e!="object"||e===null||typeof e.max_usd_per_call!="number"?{status:r}:e.mode==="reported"?{status:r,price:{mode:"reported",unit:null,usd_per_unit:null,max_usd_per_call:e.max_usd_per_call}}:e.mode==="unit"&&typeof e.unit=="string"&&typeof e.usd_per_unit=="number"&&Number.isFinite(e.usd_per_unit)&&e.usd_per_unit>=0?{status:r,price:{mode:"unit",unit:e.unit,usd_per_unit:e.usd_per_unit,max_usd_per_call:e.max_usd_per_call}}:{status:r}}i(x,"parseSpendAnswer");async function T(t,n){const{privateKey:r}=t,e=typeof r=="string";let o=!e&&r.kty==="RSA"?"RS256":"ES256";if(!e&&r.kty!=="RSA"&&r.kty!=="EC")throw new c("Ledger private key must be an ES256 or RS256 PEM or JWK",!0);const u={name:"ECDSA",namedCurve:"P-256"},a={name:"RSASSA-PKCS1-v1_5",hash:"SHA-256"};let s;try{if(e){const p=/^-----BEGIN PRIVATE KEY-----\s*([A-Za-z0-9+/=\s]+)-----END PRIVATE KEY-----\s*$/.exec(r);if(!p)throw new Error("Expected a PKCS#8 PEM private key");const y=Uint8Array.from(atob(p[1].replace(/\s/g,"")),E=>E.charCodeAt(0));try{s=await crypto.subtle.importKey("pkcs8",y,u,!1,["sign"])}catch{o="RS256",s=await crypto.subtle.importKey("pkcs8",y,a,!1,["sign"])}}else{if(r.alg!==void 0&&r.alg!==o)throw new Error(`JWK alg must be ${o}`);s=await crypto.subtle.importKey("jwk",r,o==="ES256"?u:a,!1,["sign"])}}catch(p){throw new c(`Invalid ledger private key: ${String(p)}`,!0)}const d=Math.floor(Date.now()/1e3),h=i(p=>g(new TextEncoder().encode(JSON.stringify(p))),"encode"),f=t.keyId??(e?void 0:r.kid),S=h({alg:o,typ:"JWT",...f?{kid:f}:{}}),_=h({iss:t.clientId,sub:t.clientId,aud:n,iat:d,exp:d+60,jti:crypto.randomUUID()}),w=`${S}.${_}`,k=await crypto.subtle.sign(o==="ES256"?{name:"ECDSA",hash:"SHA-256"}:"RSASSA-PKCS1-v1_5",s,new TextEncoder().encode(w));return`${w}.${g(new Uint8Array(k))}`}i(T,"clientAssertion");function g(t){let n="";for(const r of t)n+=String.fromCharCode(r);return btoa(n).replace(/=/g,"").replace(/\+/g,"-").replace(/\//g,"_")}i(g,"base64url");function l(t){return t>=400&&t<500&&t!==408&&t!==429}i(l,"isPermanent");export{c as ReceiptPostError,A as SpendRefusedError,C as postReceipt,$ as requestSpendCheck};
|
package/dist/serve.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
var W=Object.defineProperty;var c=(n,e)=>W(n,"name",{value:e,configurable:!0});import{approvalFor as Z}from"./approval.js";import{requestSpendCheck as K}from"./receipts.js";import{validateDeclaredResult as Q}from"@north-light/api/command-manifest";import{errorResponse as $,LeafError as R,leafErrorResponse as B,storedAnswerHeaders as X}from"./errors.js";import{buildCommandManifest as Y}from"./manifest.js";import{buildBundle as ee}from"./bundle.js";import{createCallerVerifier as ne,normalizeProviderId as te,scopeMissing as re}from"./verify.js";const P=/^v([1-9][0-9]*)$/;function $e(n,e={}){if(e.token!==void 0&&e.auth!==void 0)throw new Error("createFetchHandler takes either token or auth, not both");if(e.token!==void 0&&e.receipts!==void 0)throw new Error("createFetchHandler cannot meter with a static token");if(e.receipts!==void 0&&!/^app:[A-Za-z0-9_-]{1,64}$/.test(e.receipts.provider))throw new Error("createFetchHandler receipts.provider must be an app principal (app:<id>)");const i=e.auth===void 0?void 0:te(e.auth),t=Array.isArray(n)||n.version!==void 0,r=de(Array.isArray(n)?n:[n],Array.isArray(n),i),o=r[0].plugin.name;for(const u of r)for(const g of u.commands.values())if(g.leaf.sideEffect&&e.requests===void 0)throw new Error(`createFetchHandler needs requests for side-effect leaf ${g.path.join(" ")}`);const f=e.auth===void 0?void 0:ne(e.auth);if(f!==void 0){for(const u of r)for(const g of u.commands.values())if(g.scope===void 0)throw new Error(`createFetchHandler auth needs a group on every leaf; ${g.path.join(" ")} has none`)}const d=new Map,E=e.baseUrl===void 0?void 0:F(new URL(e.baseUrl).pathname),h=r[r.length-1];e.token===void 0&&e.auth===void 0&&console.warn(`crouter plugin ${o} is serving without bearer authentication`);const w=c(async(u,g,T)=>{const s=`${g.version}\0${T}`;let l=d.get(s);if(l===void 0){const A=await ee(g.plugin,{mountPath:T,...e.baseUrl!==void 0?{baseUrl:e.baseUrl}:{}});l={tar:A.tar,etag:A.etag},d.set(s,l)}const p={ETag:l.etag};return u.headers.get("if-none-match")===l.etag?new Response(null,{status:304,headers:p}):new Response(new Uint8Array(l.tar).buffer,{headers:{...p,"Content-Type":"application/x-tar"}})},"archiveResponse");return async u=>{if(u.method!=="POST"&&e.token!==void 0&&!V(u.headers.get("authorization")??"",`Bearer ${e.token}`))return $(401,{code:"unauthorized",message:"A valid bearer token is required.",next:"Send Authorization: Bearer <token>."});const g=new URL(u.url).pathname;if(u.method==="GET"){if(!t)return w(u,h,E??F(g));const m=H(g),S=m[m.length-1],_=E??F(`/${m.slice(0,-1).join("/")}`);if(S==="versions")return Response.json({latest:h.version,supported:r.map(j=>j.version)});const q=S===void 0?null:P.exec(S),b=q===null?void 0:r.find(j=>j.version===Number(q[1]));return b===void 0?U():w(u,b,`${_}/v${b.version}`)}if(u.method!=="POST")return U();const T=performance.now();let s,l,p,A=!1,z=!1;const k=c(m=>{if(l!==void 0)try{e.onAnswer?.({...s===void 0?{}:{sub:s.sub,grantee:s.grantee,initiatingApp:s.initiatingApp,act:s.act,jti:s.jti,requestId:s.requestId,...s.runId===void 0?{}:{runId:s.runId}},...s===void 0&&y!==null&&y!==""?{requestId:y}:{},tool:l.path.slice(1).join(" "),status:m.status,durationMs:performance.now()-T,...p===void 0?{}:{receiptId:p.receipt_id},replayed:z})}catch{}return m},"answer");let y=u.headers.get("crtr-request-id");try{if(l=t?ce(r,g):se(h.commands,g),e.token!==void 0&&!V(u.headers.get("authorization")??"",`Bearer ${e.token}`))return k($(401,{code:"unauthorized",message:"A valid bearer token is required.",next:"Send Authorization: Bearer <token>."}));const m=f===void 0?void 0:await f(u);if(l===void 0)return U();if(m!==void 0&&!m.scope.includes(l.scope)){s=Object.freeze({...m,requestId:y??"",...u.headers.get("crtr-run-id")?{runId:u.headers.get("crtr-run-id")}:{}});const a=l.leaf.group,v=i===void 0&&a!==void 0?m.scope.find(x=>x.endsWith(`:${a}`)&&x.length>a.length+1)?.slice(0,-(a.length+1)):void 0;throw re(l.scope,v)}if(m!==void 0){if(y===null||y==="")return k($(400,{code:"invalid_request",message:"The request carries no Crtr-Request-Id header."}));const a=u.headers.get("crtr-run-id");s=Object.freeze({...m,requestId:y,...a!==null&&a!==""?{runId:a}:{}})}const S=await ue(u,l.manifest,m!==void 0);if(l.leaf.sideEffect){if(y===null||y==="")return k($(400,{code:"invalid_request",message:"The request carries no Crtr-Request-Id header."}));const a=s===void 0?null:{sub:s.sub,grantee:s.grantee};if(!await e.requests.record(y,a)){z=!0;const x=await oe(e.requests,y,a,l.plugin.timeouts?.requestMs??3e4);if(x!==!0){const J=x.headers.get("Crtr-Receipt");if(J!==null)try{p=JSON.parse(J)}catch{}return k(x)}z=!1}A=!0}const _=l,q={request:u,...s!==void 0?{caller:s}:{},approval:Z(u),meter(a){if(s===void 0)throw new Error("ctx.meter needs an authenticated caller");if(p!==void 0)throw new Error("ctx.meter may be called only once");if(e.receipts===void 0)throw new Error("ctx.meter needs receipts with a provider and outbox");const v=_.path.slice(1).join(" "),x=a.usdEquivalent??(e.receipts.rates[v]===void 0?void 0:a.units*e.receipts.rates[v].usdPerUnit);if(x===void 0)throw new Error(`No receipt rate for ${v}`);return p={receipt_id:s.requestId,provider:e.receipts.provider,tool:v,sub:s.sub,grantee:s.grantee,payer:s.payer,units:a.units,unit:a.unit,usd_equivalent:x,request_id:s.requestId,...s.runId!==void 0?{run_id:s.runId}:{},time:new Date().toISOString()},p},async checkSpend(){if(s===void 0)throw new Error("ctx.checkSpend needs an authenticated caller");if(e.receipts===void 0)throw new Error("ctx.checkSpend needs receipts with a provider and outbox");if(e.receipts.ledger===void 0)throw new Error("ctx.checkSpend needs receipts.ledger (the provider's directory client credentials)");const a=/^Bearer ([^\s]+)$/.exec(u.headers.get("authorization")??"")?.[1];if(a===void 0)throw new Error("ctx.checkSpend needs the request's bearer token");await K(e.auth.issuer,a,e.receipts.ledger)}};let b,j;try{const a=await l.leaf.run(S,q);b=l.leaf.streaming?await me(a,e.onError,l.path):he(a,l.manifest)}catch(a){a instanceof R?(b=B(a),j=a.headers):(O(e.onError,a,l.path),b=C(a))}if(A&&b.status===403&&await ie(b))return await e.requests.delete(y),k(D(b,p));try{if(A)await e.requests.store(y,{status:b.status,body:await b.clone().text(),...j!==void 0?{headers:{...j}}:{},...p!==void 0?{receipt:p}:{}});else if(p!==void 0){if(e.receipts===void 0)throw new Error(`No receipt outbox for ${_.path.join(" ")}`);await e.receipts.outbox.save(p)}}catch(a){return O(e.onError,a,l.path,p),k(C(a))}return k(D(b,p))}catch(m){return m instanceof R?k(B(m)):(O(e.onError,m,l?.path??[]),k(C(m)))}}}c($e,"createFetchHandler");function C(n){return $(500,{code:"handler_failed",message:n instanceof Error?n.message:String(n)})}c(C,"handlerFailed");function D(n,e){if(e===void 0)return n;const i=new Headers(n.headers);return i.set("Crtr-Receipt",JSON.stringify(e)),new Response(n.body,{status:n.status,headers:i})}c(D,"responseWithReceipt");async function ie(n){try{const e=await n.clone().json();return M(e)&&M(e.error)&&e.error.code==="approval_required"}catch{return!1}}c(ie,"isApprovalRequired");async function oe(n,e,i,t){for(;;){const r=await n.read(e);if(r===null){if(await n.record(e,i))return!0;continue}if(r.identity?.sub!==i?.sub||r.identity?.grantee!==i?.grantee)return $(403,{code:"provider_refused",message:"This request id belongs to a different caller."});if(r.answer!==null){const o=X(r.answer.headers);return o.set("Content-Type","application/json"),r.answer.receipt!==void 0&&o.set("Crtr-Receipt",JSON.stringify(r.answer.receipt)),new Response(r.answer.body,{status:r.answer.status,headers:o})}if(Date.now()>=new Date(r.recordedAt).getTime()+t)return C(new Error("The earlier request has no stored answer."));await new Promise(o=>setTimeout(o,Math.min(25,Math.max(1,new Date(r.recordedAt).getTime()+t-Date.now()))))}}c(oe,"replay");function de(n,e,i){if(n.length===0)throw new Error("createFetchHandler needs at least one plugin definition");const t=n[0].name,r=new Map;for(const o of n){if(o.name!==t)throw new Error(`createFetchHandler serves one plugin; got ${t} and ${o.name}`);if(e&&o.version===void 0)throw new Error(`createFetchHandler needs an explicit version on every definition in an array; ${t} has none`);const f=o.version??1;if(r.has(f))throw new Error(`createFetchHandler got two definitions of ${t} major ${f}`);r.set(f,{version:f,plugin:o,commands:ae(o,i)})}return[...r.values()].sort((o,f)=>o.version-f.version)}c(de,"servedMajors");function ae(n,e){const t=Y(n,{mountPath:""}).mounts[0]?.node;if(t===void 0||t.kind!=="branch")throw new Error("Generated plugin manifest has no root branch");const r=new Map;return G(t.children,n.commands,[t.name],n,r,e),r}c(ae,"commandMap");function G(n,e,i,t,r,o){const f=Object.entries(e);if(n.length!==f.length)throw new Error("Generated plugin manifest does not match the command definition");for(const[d,[E,h]]of f.entries()){const w=n[d];if(w===void 0||w.kind!==h.kind)throw new Error(`Generated plugin manifest does not match command ${E}`);const u=[...i,w.name];if(w.kind==="branch"&&h.kind==="branch"){G(w.children,h.children,u,t,r,o);continue}if(w.kind==="leaf"&&h.kind==="leaf"){r.set(u.join("/"),{path:u,leaf:h,plugin:t,manifest:w,...h.group!==void 0?{scope:`${o??t.name}:${h.group}`}:{}});continue}throw new Error(`Generated plugin manifest does not match command ${E}`)}}c(G,"collectCommands");function se(n,e){const i=H(e);for(let t=0;t<i.length;t+=1){const r=n.get(i.slice(t).join("/"));if(r!==void 0)return r}}c(se,"findCommand");function ce(n,e){const i=H(e);for(let t=i.length-2;t>=0;t-=1){const r=P.exec(i[t]);if(r===null)continue;const f=n.find(d=>d.version===Number(r[1]))?.commands.get(i.slice(t+1).join("/"));if(f!==void 0)return f}}c(ce,"findVersionedCommand");function H(n){return n.split("/").filter(e=>e.length>0)}c(H,"pathSegments");async function ue(n,e,i){const t=n.body===null?"":await n.text();let r;if(t.length===0)r={};else try{const d=JSON.parse(t);if(!M(d))throw new Error("not an object");r=d}catch{throw N("Request body must be a JSON object.",i?"body":void 0,i,t)}const o=new Map(e.params.map(d=>[d.name,d]));for(const d of Object.keys(r))if(!o.has(d))throw i?N(`Unknown parameter ${d}.`,d,!0,r[d]):new R({code:"unknown_parameter",message:`Unknown parameter ${d}.`,field:d});const f={};for(const d of e.params){if(!Object.hasOwn(r,d.name)){if(d.required)throw N(`Missing required parameter ${d.name}.`,d.name,i,null);continue}const E=r[d.name],h=fe(E,d);if(h===void 0)throw N(`Invalid value for parameter ${d.name}.`,d.name,i,E);f[we(d.name)]=h}return f}c(ue,"decodeInput");function fe(n,e){if(e.kind!=="stdin"&&e.kind!=="context-file"&&e.repeatable){if(!Array.isArray(n))return;const i=n.map(t=>L(t,e));return i.some(t=>t===void 0)?void 0:i}return L(n,e)}c(fe,"decodeParameter");function L(n,e){if(e.kind==="stdin"||e.kind==="positional")return typeof n=="string"?n:void 0;if(e.kind!=="context-file"){if(e.type==="string")return typeof n=="string"?n:void 0;if(e.type==="int")return typeof n=="number"&&Number.isInteger(n)?n:void 0;if(e.type==="bool")return typeof n=="boolean"?n:void 0;if(e.type==="enum")return typeof n=="string"&&Array.isArray(e.choices)&&e.choices.includes(n)?n:void 0;if(e.type==="file")return typeof n=="string"?n:void 0;if(e.type==="path")return typeof n!="string"?void 0:e.encoding==="base64"?le(n):n}}c(L,"decodeScalar");function le(n){if(!(!/^[A-Za-z0-9+/]*={0,2}$/.test(n)||n.length%4!==0))try{const e=atob(n);return Uint8Array.from(e,i=>i.charCodeAt(0))}catch{return}}c(le,"decodeBase64");function he(n,e){let i,t;try{i=JSON.stringify(n),t=JSON.parse(i)}catch{throw I("result")}if(!M(t))throw I("result");const r=Q(e.output,t);if(r!==null)throw I(r.field,r.receivedType);for(const o of Object.keys(t))if(!e.output.some(f=>f.name===o))throw I(o);return new Response(i,{headers:{"Content-Type":"application/json"}})}c(he,"resultResponse");async function me(n,e,i){if(!pe(n))throw I("result");const t=n[Symbol.asyncIterator](),r=new TextEncoder,o=await t.next();let f=o.done,d=f?void 0:r.encode(`${JSON.stringify(o.value)}
|
|
2
|
-
`);const
|
|
3
|
-
`))}catch(
|
|
1
|
+
var Z=Object.defineProperty;var c=(n,e)=>Z(n,"name",{value:e,configurable:!0});import{approvalFor as K}from"./approval.js";import{requestSpendCheck as Q}from"./receipts.js";import{validateDeclaredResult as X}from"@north-light/api/command-manifest";import{errorResponse as v,LeafError as I,leafErrorResponse as P,storedAnswerHeaders as Y}from"./errors.js";import{buildCommandManifest as ee}from"./manifest.js";import{buildBundle as ne}from"./bundle.js";import{createCallerVerifier as te,normalizeProviderId as re,scopeMissing as ie}from"./verify.js";const D=/^v([1-9][0-9]*)$/;function Ae(n,e={}){if(e.token!==void 0&&e.auth!==void 0)throw new Error("createFetchHandler takes either token or auth, not both");if(e.token!==void 0&&e.receipts!==void 0)throw new Error("createFetchHandler cannot meter with a static token");if(e.receipts!==void 0&&!/^app:[A-Za-z0-9_-]{1,64}$/.test(e.receipts.provider))throw new Error("createFetchHandler receipts.provider must be an app principal (app:<id>)");const i=e.auth===void 0?void 0:re(e.auth),t=Array.isArray(n)||n.version!==void 0,r=ae(Array.isArray(n)?n:[n],Array.isArray(n),i),o=r[0].plugin.name;for(const u of r)for(const g of u.commands.values())if(g.leaf.sideEffect&&e.requests===void 0)throw new Error(`createFetchHandler needs requests for side-effect leaf ${g.path.join(" ")}`);const f=e.auth===void 0?void 0:te(e.auth);if(f!==void 0){for(const u of r)for(const g of u.commands.values())if(g.scope===void 0)throw new Error(`createFetchHandler auth needs a group on every leaf; ${g.path.join(" ")} has none`)}const d=new Map,k=e.baseUrl===void 0?void 0:J(new URL(e.baseUrl).pathname),h=r[r.length-1];e.token===void 0&&e.auth===void 0&&console.warn(`crouter plugin ${o} is serving without bearer authentication`);const p=c(async(u,g,T)=>{const s=`${g.version}\0${T}`;let l=d.get(s);if(l===void 0){const $=await ne(g.plugin,{mountPath:T,...e.baseUrl!==void 0?{baseUrl:e.baseUrl}:{}});l={tar:$.tar,etag:$.etag},d.set(s,l)}const w={ETag:l.etag};return u.headers.get("if-none-match")===l.etag?new Response(null,{status:304,headers:w}):new Response(new Uint8Array(l.tar).buffer,{headers:{...w,"Content-Type":"application/x-tar"}})},"archiveResponse");return async u=>{if(u.method!=="POST"&&e.token!==void 0&&!W(u.headers.get("authorization")??"",`Bearer ${e.token}`))return v(401,{code:"unauthorized",message:"A valid bearer token is required.",next:"Send Authorization: Bearer <token>."});const g=new URL(u.url).pathname;if(u.method==="GET"){if(!t)return p(u,h,k??J(g));const m=U(g),A=m[m.length-1],S=k??J(`/${m.slice(0,-1).join("/")}`);if(A==="versions")return Response.json({latest:h.version,supported:r.map(b=>b.version)});const j=A===void 0?null:D.exec(A),R=j===null?void 0:r.find(b=>b.version===Number(j[1]));return R===void 0?F():p(u,R,`${S}/v${R.version}`)}if(u.method!=="POST")return F();const T=performance.now();let s,l,w,$=!1,z=!1;const E=c(m=>{if(l!==void 0)try{e.onAnswer?.({...s===void 0?{}:{sub:s.sub,grantee:s.grantee,initiatingApp:s.initiatingApp,act:s.act,jti:s.jti,requestId:s.requestId,...s.runId===void 0?{}:{runId:s.runId}},...s===void 0&&y!==null&&y!==""?{requestId:y}:{},tool:l.path.slice(1).join(" "),status:m.status,durationMs:performance.now()-T,...w===void 0?{}:{receiptId:w.receipt_id},replayed:z})}catch{}return m},"answer");let y=u.headers.get("crtr-request-id");try{if(l=t?ue(r,g):ce(h.commands,g),e.token!==void 0&&!W(u.headers.get("authorization")??"",`Bearer ${e.token}`))return E(v(401,{code:"unauthorized",message:"A valid bearer token is required.",next:"Send Authorization: Bearer <token>."}));const m=f===void 0?void 0:await f(u);if(l===void 0)return F();if(m!==void 0&&!m.scope.includes(l.scope)){s=Object.freeze({...m,requestId:y??"",...u.headers.get("crtr-run-id")?{runId:u.headers.get("crtr-run-id")}:{}});const a=l.leaf.group,q=i===void 0&&a!==void 0?m.scope.find(x=>x.endsWith(`:${a}`)&&x.length>a.length+1)?.slice(0,-(a.length+1)):void 0;throw ie(l.scope,q)}if(m!==void 0){if(y===null||y==="")return E(v(400,{code:"invalid_request",message:"The request carries no Crtr-Request-Id header."}));const a=u.headers.get("crtr-run-id");s=Object.freeze({...m,requestId:y,...a!==null&&a!==""?{runId:a}:{}})}const A=await fe(u,l.manifest,m!==void 0);if(l.leaf.sideEffect){if(y===null||y==="")return E(v(400,{code:"invalid_request",message:"The request carries no Crtr-Request-Id header."}));const a=s===void 0?null:{sub:s.sub,grantee:s.grantee};if(!await e.requests.record(y,a)){z=!0;const x=await de(e.requests,y,a,l.plugin.timeouts?.requestMs??3e4);if(x!==!0){const B=x.headers.get("Crtr-Receipt");if(B!==null)try{w=JSON.parse(B)}catch{}return E(x)}z=!1}$=!0}const S=l;let j;const R={request:u,...s!==void 0?{caller:s}:{},approval:K(u),meter(a){if(s===void 0)throw new Error("ctx.meter needs an authenticated caller");if(w!==void 0)throw new Error("ctx.meter may be called only once");if(e.receipts===void 0)throw new Error("ctx.meter needs receipts with a provider and outbox");const q=S.path.slice(1).join(" "),x=a.usdEquivalent??(j?.mode==="unit"&&j.usd_per_unit!==null?a.units*j.usd_per_unit:0);return w={receipt_id:s.requestId,provider:e.receipts.provider,tool:q,sub:s.sub,grantee:s.grantee,payer:s.payer,units:a.units,unit:a.unit,usd_equivalent:x,request_id:s.requestId,...s.runId!==void 0?{run_id:s.runId}:{},time:new Date().toISOString()},w},async checkSpend(){if(s===void 0)throw new Error("ctx.checkSpend needs an authenticated caller");if(e.receipts===void 0)throw new Error("ctx.checkSpend needs receipts with a provider and outbox");if(e.receipts.ledger===void 0)throw new Error("ctx.checkSpend needs receipts.ledger (the provider's directory client credentials)");const a=/^Bearer ([^\s]+)$/.exec(u.headers.get("authorization")??"")?.[1];if(a===void 0)throw new Error("ctx.checkSpend needs the request's bearer token");j=(await Q(e.auth.issuer,a,S.path.slice(1).join(" "),e.receipts.ledger)).price}};let b,H;try{const a=await l.leaf.run(A,R);b=l.leaf.streaming?await pe(a,e.onError,l.path):me(a,l.manifest)}catch(a){a instanceof I?(b=P(a),H=a.headers):(O(e.onError,a,l.path),b=C(a))}if($&&b.status===403&&await oe(b))return await e.requests.delete(y),E(G(b,w));try{if($)await e.requests.store(y,{status:b.status,body:await b.clone().text(),...H!==void 0?{headers:{...H}}:{},...w!==void 0?{receipt:w}:{}});else if(w!==void 0){if(e.receipts===void 0)throw new Error(`No receipt outbox for ${S.path.join(" ")}`);await e.receipts.outbox.save(w)}}catch(a){return O(e.onError,a,l.path,w),E(C(a))}return E(G(b,w))}catch(m){return m instanceof I?E(P(m)):(O(e.onError,m,l?.path??[]),E(C(m)))}}}c(Ae,"createFetchHandler");function C(n){return v(500,{code:"handler_failed",message:n instanceof Error?n.message:String(n)})}c(C,"handlerFailed");function G(n,e){if(e===void 0)return n;const i=new Headers(n.headers);return i.set("Crtr-Receipt",JSON.stringify(e)),new Response(n.body,{status:n.status,headers:i})}c(G,"responseWithReceipt");async function oe(n){try{const e=await n.clone().json();return M(e)&&M(e.error)&&e.error.code==="approval_required"}catch{return!1}}c(oe,"isApprovalRequired");async function de(n,e,i,t){for(;;){const r=await n.read(e);if(r===null){if(await n.record(e,i))return!0;continue}if(r.identity?.sub!==i?.sub||r.identity?.grantee!==i?.grantee)return v(403,{code:"provider_refused",message:"This request id belongs to a different caller."});if(r.answer!==null){const o=Y(r.answer.headers);return o.set("Content-Type","application/json"),r.answer.receipt!==void 0&&o.set("Crtr-Receipt",JSON.stringify(r.answer.receipt)),new Response(r.answer.body,{status:r.answer.status,headers:o})}if(Date.now()>=new Date(r.recordedAt).getTime()+t)return C(new Error("The earlier request has no stored answer."));await new Promise(o=>setTimeout(o,Math.min(25,Math.max(1,new Date(r.recordedAt).getTime()+t-Date.now()))))}}c(de,"replay");function ae(n,e,i){if(n.length===0)throw new Error("createFetchHandler needs at least one plugin definition");const t=n[0].name,r=new Map;for(const o of n){if(o.name!==t)throw new Error(`createFetchHandler serves one plugin; got ${t} and ${o.name}`);if(e&&o.version===void 0)throw new Error(`createFetchHandler needs an explicit version on every definition in an array; ${t} has none`);const f=o.version??1;if(r.has(f))throw new Error(`createFetchHandler got two definitions of ${t} major ${f}`);r.set(f,{version:f,plugin:o,commands:se(o,i)})}return[...r.values()].sort((o,f)=>o.version-f.version)}c(ae,"servedMajors");function se(n,e){const t=ee(n,{mountPath:""}).mounts[0]?.node;if(t===void 0||t.kind!=="branch")throw new Error("Generated plugin manifest has no root branch");const r=new Map;return L(t.children,n.commands,[t.name],n,r,e),r}c(se,"commandMap");function L(n,e,i,t,r,o){const f=Object.entries(e);if(n.length!==f.length)throw new Error("Generated plugin manifest does not match the command definition");for(const[d,[k,h]]of f.entries()){const p=n[d];if(p===void 0||p.kind!==h.kind)throw new Error(`Generated plugin manifest does not match command ${k}`);const u=[...i,p.name];if(p.kind==="branch"&&h.kind==="branch"){L(p.children,h.children,u,t,r,o);continue}if(p.kind==="leaf"&&h.kind==="leaf"){r.set(u.join("/"),{path:u,leaf:h,plugin:t,manifest:p,...h.group!==void 0?{scope:`${o??t.name}:${h.group}`}:{}});continue}throw new Error(`Generated plugin manifest does not match command ${k}`)}}c(L,"collectCommands");function ce(n,e){const i=U(e);for(let t=0;t<i.length;t+=1){const r=n.get(i.slice(t).join("/"));if(r!==void 0)return r}}c(ce,"findCommand");function ue(n,e){const i=U(e);for(let t=i.length-2;t>=0;t-=1){const r=D.exec(i[t]);if(r===null)continue;const f=n.find(d=>d.version===Number(r[1]))?.commands.get(i.slice(t+1).join("/"));if(f!==void 0)return f}}c(ue,"findVersionedCommand");function U(n){return n.split("/").filter(e=>e.length>0)}c(U,"pathSegments");async function fe(n,e,i){const t=n.body===null?"":await n.text();let r;if(t.length===0)r={};else try{const d=JSON.parse(t);if(!M(d))throw new Error("not an object");r=d}catch{throw N("Request body must be a JSON object.",i?"body":void 0,i,t)}const o=new Map(e.params.map(d=>[d.name,d]));for(const d of Object.keys(r))if(!o.has(d))throw i?N(`Unknown parameter ${d}.`,d,!0,r[d]):new I({code:"unknown_parameter",message:`Unknown parameter ${d}.`,field:d});const f={};for(const d of e.params){if(!Object.hasOwn(r,d.name)){if(d.required)throw N(`Missing required parameter ${d.name}.`,d.name,i,null);continue}const k=r[d.name],h=le(k,d);if(h===void 0)throw N(`Invalid value for parameter ${d.name}.`,d.name,i,k);f[we(d.name)]=h}return f}c(fe,"decodeInput");function le(n,e){if(e.kind!=="stdin"&&e.kind!=="context-file"&&e.repeatable){if(!Array.isArray(n))return;const i=n.map(t=>V(t,e));return i.some(t=>t===void 0)?void 0:i}return V(n,e)}c(le,"decodeParameter");function V(n,e){if(e.kind==="stdin"||e.kind==="positional")return typeof n=="string"?n:void 0;if(e.kind!=="context-file"){if(e.type==="string")return typeof n=="string"?n:void 0;if(e.type==="int")return typeof n=="number"&&Number.isInteger(n)?n:void 0;if(e.type==="bool")return typeof n=="boolean"?n:void 0;if(e.type==="enum")return typeof n=="string"&&Array.isArray(e.choices)&&e.choices.includes(n)?n:void 0;if(e.type==="file")return typeof n=="string"?n:void 0;if(e.type==="path")return typeof n!="string"?void 0:e.encoding==="base64"?he(n):n}}c(V,"decodeScalar");function he(n){if(!(!/^[A-Za-z0-9+/]*={0,2}$/.test(n)||n.length%4!==0))try{const e=atob(n);return Uint8Array.from(e,i=>i.charCodeAt(0))}catch{return}}c(he,"decodeBase64");function me(n,e){let i,t;try{i=JSON.stringify(n),t=JSON.parse(i)}catch{throw _("result")}if(!M(t))throw _("result");const r=X(e.output,t);if(r!==null)throw _(r.field,r.receivedType);for(const o of Object.keys(t))if(!e.output.some(f=>f.name===o))throw _(o);return new Response(i,{headers:{"Content-Type":"application/json"}})}c(me,"resultResponse");async function pe(n,e,i){if(!ge(n))throw _("result");const t=n[Symbol.asyncIterator](),r=new TextEncoder,o=await t.next();let f=o.done,d=f?void 0:r.encode(`${JSON.stringify(o.value)}
|
|
2
|
+
`);const k=new ReadableStream({async pull(h){if(d!==void 0){h.enqueue(d),d=void 0;return}if(f){h.close();return}try{const p=await t.next();p.done?(f=!0,h.close()):h.enqueue(r.encode(`${JSON.stringify(p.value)}
|
|
3
|
+
`))}catch(p){O(e,p,i),h.error(p)}},async cancel(h){await t.return?.(h)}});return new Response(k,{headers:{"Content-Type":"application/x-ndjson"}})}c(pe,"streamResponse");function O(n,e,i,t){try{n?.(e,i,t)}catch{}}c(O,"reportUnhandledError");function _(n,e){return new I({code:"handler_output_invalid",message:`Handler returned an invalid value for ${n}.`,status:500,field:n,...e!==void 0?{received:e}:{}})}c(_,"outputInvalid");function N(n,e,i=!1,t){return new I({code:i?"invalid_argument":"invalid_input",message:n,...e!==void 0?{field:e}:{},...i?{received:t}:{}})}c(N,"invalidInput");function F(){return v(404,{code:"no_such_command",message:"No command matches this request."})}c(F,"noSuchCommand");function J(n){return n==="/"?"":n.replace(/\/+$/,"")}c(J,"mountPath");function we(n){return n.replace(/-([a-z])/g,(e,i)=>i.toUpperCase())}c(we,"camel");function W(n,e){const i=new TextEncoder().encode(n),t=new TextEncoder().encode(e);let r=i.length^t.length;for(let o=0;o<Math.min(i.length,t.length);o+=1)r|=i[o]^t[o];return r===0}c(W,"constantTimeEqual");function M(n){return typeof n=="object"&&n!==null&&!Array.isArray(n)}c(M,"isRecord");function ge(n){return typeof n=="object"&&n!==null&&Symbol.asyncIterator in n}c(ge,"isAsyncIterable");export{Ae as createFetchHandler};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/plugin",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.489",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"postpack": "node ../../scripts/pack-minify.mjs postpack packages/crouter-plugin"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@north-light/api": "0.3.
|
|
27
|
-
"@north-light/identity": "0.3.
|
|
26
|
+
"@north-light/api": "0.3.489",
|
|
27
|
+
"@north-light/identity": "0.3.489"
|
|
28
28
|
},
|
|
29
29
|
"license": "UNLICENSED",
|
|
30
30
|
"publishConfig": {
|