@usagekit/meter 0.7.0 → 0.9.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/dist/catalog.d.ts CHANGED
@@ -1,10 +1,14 @@
1
- import type { ConnectionPolicy, MeterReserveInput, Operation, PricingCatalog, ReserveInput, ValidationFailure } from "@usagekit/core";
2
- /** Host lookup of a connection's provider, plan, manual prices and tracking policy. */
3
- export type ConnectionResolver = (connectionId: string) => Promise<ConnectionPolicy | undefined> | ConnectionPolicy | undefined;
4
- /** What reserve does with a request after the catalog and the connection policy spoke. */
5
- export type ReserveResolution = {
1
+ import type { ConnectionPolicy, MeterReserveInput, MeterQuoteInput, Operation, PricingCatalog, ReserveInput, ValidationFailure } from "@usagekit/core";
2
+ /** Host lookup of actual funding and pricing policy within the requested namespace. */
3
+ export type ConnectionResolver = (scope: {
4
+ namespace: string;
5
+ connection: string;
6
+ }) => Promise<ConnectionPolicy | undefined> | ConnectionPolicy | undefined;
7
+ type EstimateResolution = {
6
8
  kind: "reserve";
7
- input: ReserveInput;
9
+ input: Omit<ReserveInput, "operationId" | "reservationTtlMs" | "correlationId" | "parentOperationId">;
10
+ checkedAt?: string;
11
+ sampleSize?: number;
8
12
  } | {
9
13
  kind: "count";
10
14
  state: "passthrough" | "unpriced";
@@ -12,4 +16,15 @@ export type ReserveResolution = {
12
16
  kind: "invalid";
13
17
  failure: ValidationFailure;
14
18
  };
19
+ export type ReserveResolution = Exclude<EstimateResolution, {
20
+ kind: "reserve";
21
+ }> | {
22
+ kind: "reserve";
23
+ input: ReserveInput;
24
+ };
25
+ export declare function validateConnectionFunding(policy: ConnectionPolicy, fundingSource: ConnectionPolicy["fundingSource"], field?: string): ValidationFailure | null;
26
+ /** Shared estimate resolution. No Store reads, writes, expiry maintenance or provider calls. */
27
+ export declare function resolveEstimate(requested: MeterQuoteInput, catalog?: PricingCatalog, resolveConnection?: ConnectionResolver): Promise<EstimateResolution>;
28
+ /** Replay retains the frozen estimate and provenance, even after funding or policy changes. */
15
29
  export declare function resolveReserve(requested: MeterReserveInput, existing: Operation | null, catalog?: PricingCatalog, resolveConnection?: ConnectionResolver): Promise<ReserveResolution>;
30
+ export {};
package/dist/catalog.js CHANGED
@@ -2,61 +2,116 @@ const invalid = (field, reason) => ({
2
2
  kind: "invalid",
3
3
  failure: { outcome: "invalid", field, reason },
4
4
  });
5
- /**
6
- * Order: a stored operation replays with its own estimate; then the connection's tracking
7
- * policy and free catalog operations count as passthrough; then a given estimate is used as is;
8
- * then the catalog resolves one (manual, measured, list, unknown), and an operation the catalog
9
- * does not know counts as unpriced.
10
- */
11
5
  const provenanceOf = (estimateSource, providerPriceVersion) => ({
12
6
  ...(estimateSource ? { estimateSource } : {}),
13
7
  ...(providerPriceVersion ? { providerPriceVersion } : {}),
14
8
  });
15
- export async function resolveReserve(requested, existing, catalog, resolveConnection) {
16
- const { options = {}, estimate, estimateSource, providerPriceVersion, ...rest } = requested;
17
- // A retry that omits the estimate replays the stored one, including provenance. A retry that
18
- // sends an estimate keeps only the provenance on that request.
19
- if (existing) {
20
- if (estimate === undefined)
21
- return {
22
- kind: "reserve",
23
- input: {
24
- ...rest,
25
- estimate: existing.estimate,
26
- ...provenanceOf(existing.estimateSource, existing.providerPriceVersion),
27
- },
28
- };
9
+ export function validateConnectionFunding(policy, fundingSource, field = "fundingSource") {
10
+ if (policy.fundingSource !== "byok" && policy.fundingSource !== "platform")
29
11
  return {
30
- kind: "reserve",
31
- input: { ...rest, estimate, ...provenanceOf(estimateSource, providerPriceVersion) },
12
+ outcome: "invalid",
13
+ field: "fundingSource",
14
+ reason: "missing from the resolved connection policy",
32
15
  };
33
- }
34
- const policy = catalog && resolveConnection ? await resolveConnection(rest.scope.connection) : undefined;
16
+ if (policy.fundingSource !== fundingSource)
17
+ return { outcome: "invalid", field, reason: "differs from the connection funding source" };
18
+ return null;
19
+ }
20
+ /** Shared estimate resolution. No Store reads, writes, expiry maintenance or provider calls. */
21
+ export async function resolveEstimate(requested, catalog, resolveConnection) {
22
+ const { options = {}, estimate, estimateSource, providerPriceVersion, ...rest } = requested;
23
+ const policy = await resolveConnection?.({
24
+ namespace: rest.scope.namespace,
25
+ connection: rest.scope.connection,
26
+ });
27
+ if (resolveConnection && !policy)
28
+ return invalid("estimate", "unknown connection");
35
29
  if (policy && policy.provider !== rest.provider)
36
30
  return invalid("provider", "differs from the connection provider");
31
+ if (policy) {
32
+ const failure = validateConnectionFunding(policy, rest.fundingSource);
33
+ if (failure)
34
+ return { kind: "invalid", failure };
35
+ }
37
36
  if (policy && catalog) {
38
37
  const known = catalog.operationsOf(policy.provider).find((o) => o.id === rest.operation);
39
- if (policy.tracking?.[rest.operation] === "passthrough" || known?.billable === false)
38
+ if (known?.billable === false)
39
+ return { kind: "count", state: "passthrough" };
40
+ // Only catalog-declared nonbillable operations can omit platform customer pricing.
41
+ // Platform calls with a host estimate always reserve; only BYOK can opt out via tracking.
42
+ if (rest.fundingSource === "platform" && estimate === undefined)
43
+ return invalid("estimate", "required from host customer-price policy for platform funding");
44
+ if (rest.fundingSource === "byok" && policy.tracking?.[rest.operation] === "passthrough")
40
45
  return { kind: "count", state: "passthrough" };
41
46
  if (estimate === undefined) {
42
47
  if (!known)
43
48
  return { kind: "count", state: "unpriced" };
44
- const { tracking: _, ...connection } = policy, resolved = catalog.estimate({ ...connection, id: rest.scope.connection }, rest.operation, options);
45
- return {
46
- kind: "reserve",
47
- input: {
48
- ...rest,
49
- estimate: resolved.quantities,
50
- estimateSource: resolved.source,
51
- ...(resolved.priceVersion ? { providerPriceVersion: resolved.priceVersion } : {}),
52
- },
53
- };
49
+ const { tracking: _, ...connection } = policy;
50
+ try {
51
+ const resolved = catalog.estimate({ ...connection, id: rest.scope.connection, fundingSource: rest.fundingSource }, rest.operation, options);
52
+ return {
53
+ kind: "reserve",
54
+ input: {
55
+ ...rest,
56
+ estimate: resolved.quantities,
57
+ estimateSource: resolved.source,
58
+ ...(resolved.priceVersion ? { providerPriceVersion: resolved.priceVersion } : {}),
59
+ },
60
+ ...(resolved.checkedAt ? { checkedAt: resolved.checkedAt } : {}),
61
+ ...(resolved.sampleSize !== undefined ? { sampleSize: resolved.sampleSize } : {}),
62
+ };
63
+ }
64
+ catch (error) {
65
+ if (error instanceof Error && /^(InvalidInput|InvalidDecimal):/.test(error.message))
66
+ return invalid("options", error.message);
67
+ throw error;
68
+ }
54
69
  }
55
70
  }
56
- if (estimate === undefined)
71
+ if (estimate === undefined) {
57
72
  return invalid("estimate", catalog && resolveConnection ? "unknown connection" : "required without a catalog");
73
+ }
74
+ if (rest.fundingSource === "platform") {
75
+ if (!estimate.some((quantity) => quantity.unit !== "requests"))
76
+ return invalid("estimate", "platform funding requires a host customer price");
77
+ if (estimateSource === "manual" || estimateSource === "list" || estimateSource === "measured")
78
+ return invalid("estimateSource", "provider prices are BYOK-only");
79
+ }
58
80
  return {
59
81
  kind: "reserve",
60
- input: { ...rest, estimate, ...provenanceOf(estimateSource, providerPriceVersion) },
82
+ input: {
83
+ ...rest,
84
+ estimate,
85
+ ...provenanceOf(estimateSource, providerPriceVersion),
86
+ },
87
+ };
88
+ }
89
+ /** Replay retains the frozen estimate and provenance, even after funding or policy changes. */
90
+ export async function resolveReserve(requested, existing, catalog, resolveConnection) {
91
+ const { options: _, estimate, estimateSource, providerPriceVersion, ...rest } = requested;
92
+ if (existing)
93
+ return {
94
+ kind: "reserve",
95
+ input: {
96
+ ...rest,
97
+ estimate: estimate ?? existing.estimate,
98
+ ...(estimate === undefined
99
+ ? provenanceOf(existing.estimateSource, existing.providerPriceVersion)
100
+ : provenanceOf(estimateSource, providerPriceVersion)),
101
+ },
102
+ };
103
+ const { operationId, reservationTtlMs, correlationId, parentOperationId, ...quote } = requested;
104
+ const resolved = await resolveEstimate(quote, catalog, resolveConnection);
105
+ if (resolved.kind !== "reserve")
106
+ return resolved;
107
+ return {
108
+ ...resolved,
109
+ input: {
110
+ ...resolved.input,
111
+ operationId,
112
+ ...(reservationTtlMs !== undefined ? { reservationTtlMs: reservationTtlMs } : {}),
113
+ ...(correlationId !== undefined ? { correlationId: correlationId } : {}),
114
+ ...(parentOperationId !== undefined ? { parentOperationId: parentOperationId } : {}),
115
+ },
61
116
  };
62
117
  }
package/dist/meter.js CHANGED
@@ -1,7 +1,7 @@
1
- import { resolveReserve } from "./catalog.js";
1
+ import { resolveEstimate, resolveReserve } from "./catalog.js";
2
2
  import { InvalidInput } from "@usagekit/store";
3
3
  import { defaultPolicy } from "./policy.js";
4
- import { validate, reserveValidation, usageValidation, operationsValidation, sourceValidation, provenanceValidation, requestCountsValidation, } from "./validation.js";
4
+ import { validate, quoteValidation, reserveValidation, usageValidation, operationsValidation, sourceValidation, provenanceValidation, requestCountsValidation, } from "./validation.js";
5
5
  import { canRead, canReadBudget, canReadShared, isShared } from "./access.js";
6
6
  import { defaultBudgetOrder, normalizeTags } from "@usagekit/core";
7
7
  import { validateBillingImportInput, validateBillingImportsQuery } from "@usagekit/store";
@@ -30,7 +30,86 @@ export function createMeter({ store, policy = defaultPolicy, clock, resolveOwner
30
30
  return invalid ? Promise.resolve(invalid) : safely(fn);
31
31
  };
32
32
  const read = async (fn) => safely(async () => ({ outcome: "ok", value: await fn() }));
33
- return {
33
+ const meter = {
34
+ quote: async (access, requested) => {
35
+ const invalid = quoteValidation(requested);
36
+ if (invalid)
37
+ return invalid;
38
+ if (!access.canReadBillingDetail ||
39
+ !canRead(access, {
40
+ kind: "principal",
41
+ namespace: requested.scope.namespace,
42
+ principal: requested.scope.principal,
43
+ }) ||
44
+ (requested.scope.group !== undefined &&
45
+ !canRead(access, {
46
+ kind: "group",
47
+ namespace: requested.scope.namespace,
48
+ group: requested.scope.group,
49
+ })))
50
+ return { outcome: "forbidden" };
51
+ const owner = await resolveOwnership?.({
52
+ kind: "connection",
53
+ namespace: requested.scope.namespace,
54
+ connection: requested.scope.connection,
55
+ });
56
+ if (!owner ||
57
+ !canRead(access, owner) ||
58
+ (owner.kind === "principal"
59
+ ? owner.principal !== requested.scope.principal
60
+ : owner.group !== requested.scope.group))
61
+ return { outcome: "forbidden" };
62
+ if (requested.scope.accessCredential &&
63
+ !(await canReadBudget(resolveOwnership, access, {
64
+ kind: "access_credential",
65
+ namespace: requested.scope.namespace,
66
+ accessCredential: requested.scope.accessCredential,
67
+ })))
68
+ return { outcome: "forbidden" };
69
+ return safely(async () => {
70
+ const resolved = await resolveEstimate(requested, catalog, resolveConnection);
71
+ if (resolved.kind === "invalid")
72
+ return resolved.failure;
73
+ const quantities = resolved.kind === "reserve" ? resolved.input.estimate : [];
74
+ if (resolved.kind === "reserve") {
75
+ const invalid = reserveValidation({ ...resolved.input, operationId: "quote" });
76
+ if (invalid)
77
+ return invalid;
78
+ }
79
+ const statuses = await meter.applicableBudgets(access, {
80
+ scope: requested.scope,
81
+ surface: requested.surface,
82
+ source: requested.source,
83
+ units: quantities.map((q) => q.unit),
84
+ ...(requested.platformPools ? { platformPools: requested.platformPools } : {}),
85
+ });
86
+ if (statuses.outcome !== "ok")
87
+ return statuses;
88
+ return {
89
+ outcome: "ok",
90
+ value: {
91
+ quantities,
92
+ ...(resolved.kind === "reserve"
93
+ ? resolved.input.estimateSource
94
+ ? { estimateSource: resolved.input.estimateSource }
95
+ : {}
96
+ : { estimateSource: "unknown" }),
97
+ ...(resolved.kind === "reserve" && resolved.input.providerPriceVersion
98
+ ? { providerPriceVersion: resolved.input.providerPriceVersion }
99
+ : {}),
100
+ ...(resolved.kind === "reserve" && resolved.checkedAt
101
+ ? { checkedAt: resolved.checkedAt }
102
+ : {}),
103
+ ...(resolved.kind === "reserve" && resolved.sampleSize !== undefined
104
+ ? { sampleSize: resolved.sampleSize }
105
+ : {}),
106
+ tracking: resolved.kind === "reserve" ? "metered" : resolved.state,
107
+ budgets: statuses.value,
108
+ asOf: clock.now().toISOString(),
109
+ },
110
+ };
111
+ });
112
+ },
34
113
  importBilling: async (access, i) => {
35
114
  const invalid = validateBillingImportInput(i);
36
115
  if (invalid)
@@ -54,7 +133,10 @@ export function createMeter({ store, policy = defaultPolicy, clock, resolveOwner
54
133
  : owner.group !== i.scope.group) ||
55
134
  !canRead(access, owner))
56
135
  return { outcome: "forbidden" };
57
- const connection = await resolveConnection?.(i.scope.connection);
136
+ const connection = await resolveConnection?.({
137
+ namespace: i.scope.namespace,
138
+ connection: i.scope.connection,
139
+ });
58
140
  if (resolveConnection && (!connection || connection.provider !== i.provider))
59
141
  return { outcome: "forbidden" };
60
142
  if (catalog && catalog.operationsOf(i.provider).length === 0)
@@ -246,4 +328,5 @@ export function createMeter({ store, policy = defaultPolicy, clock, resolveOwner
246
328
  });
247
329
  },
248
330
  };
331
+ return meter;
249
332
  }
@@ -1,4 +1,4 @@
1
- import type { ValidationFailure, ReserveInput, UsageQuery, ApplicableBudgetsQuery, OperationsQuery, RequestCountsQuery } from "@usagekit/core";
1
+ import type { MeterQuoteInput, ValidationFailure, ReserveInput, UsageQuery, ApplicableBudgetsQuery, OperationsQuery, RequestCountsQuery } from "@usagekit/core";
2
2
  export declare function validate(value: unknown, field?: string): ValidationFailure | null;
3
3
  /** The host sets both fields at authentication; a source outside its surface group is a forgery or a bug. */
4
4
  export declare function sourceValidation(i: Pick<ApplicableBudgetsQuery, "surface" | "source">): ValidationFailure | null;
@@ -10,3 +10,5 @@ export declare function provenanceValidation(i: {
10
10
  estimateSource?: string;
11
11
  }): ValidationFailure | null;
12
12
  export declare function requestCountsValidation(q: RequestCountsQuery): ValidationFailure | null;
13
+ /** Validate the quote's required domain fields before touching policy or Store. */
14
+ export declare function quoteValidation(i: MeterQuoteInput): ValidationFailure | null;
@@ -85,3 +85,39 @@ export function provenanceValidation(i) {
85
85
  export function requestCountsValidation(q) {
86
86
  return validate(q) ?? windowValidation(q);
87
87
  }
88
+ /** Validate the quote's required domain fields before touching policy or Store. */
89
+ export function quoteValidation(i) {
90
+ if (!i || typeof i !== "object" || !i.scope || typeof i.scope !== "object")
91
+ return { outcome: "invalid", field: "scope", reason: "required scope" };
92
+ for (const [field, value] of Object.entries({
93
+ namespace: i.scope.namespace,
94
+ principal: i.scope.principal,
95
+ connection: i.scope.connection,
96
+ costOwner: i.costOwner,
97
+ provider: i.provider,
98
+ operation: i.operation,
99
+ }))
100
+ if (typeof value !== "string" || !value.trim())
101
+ return { outcome: "invalid", field, reason: "required nonempty string" };
102
+ if (!["byok", "platform"].includes(i.fundingSource))
103
+ return { outcome: "invalid", field: "fundingSource", reason: "unknown funding source" };
104
+ if (!sourcesOf(i.surface)?.includes(i.source) || i.source === "import")
105
+ return { outcome: "invalid", field: "source", reason: "invalid quote source" };
106
+ if (i.estimate !== undefined && !Array.isArray(i.estimate))
107
+ return { outcome: "invalid", field: "estimate", reason: "expected quantities" };
108
+ if (i.estimate?.some((q) => !q ||
109
+ typeof q !== "object" ||
110
+ typeof q.unit !== "string" ||
111
+ !q.unit.trim() ||
112
+ typeof q.value !== "bigint" ||
113
+ !Number.isInteger(q.scale)))
114
+ return { outcome: "invalid", field: "estimate", reason: "expected exact quantities" };
115
+ if (i.options !== undefined &&
116
+ (typeof i.options !== "object" ||
117
+ i.options === null ||
118
+ Array.isArray(i.options) ||
119
+ Object.values(i.options).some((x) => typeof x !== "string")))
120
+ return { outcome: "invalid", field: "options", reason: "expected string options" };
121
+ return (reserveValidation({ ...i, operationId: "quote", estimate: i.estimate ?? [] }) ??
122
+ provenanceValidation(i));
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagekit/meter",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Admission, reservations, settlement and recovery.",
@@ -23,8 +23,8 @@
23
23
  "prepack": "node ../../scripts/package-license.mjs"
24
24
  },
25
25
  "dependencies": {
26
- "@usagekit/core": "0.7.0",
27
- "@usagekit/store": "0.7.0"
26
+ "@usagekit/core": "0.9.0",
27
+ "@usagekit/store": "0.9.0"
28
28
  },
29
29
  "sideEffects": false,
30
30
  "publishConfig": {
@@ -33,11 +33,11 @@
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",
36
- "url": "git+https://github.com/msniezynski/usagekit.git",
36
+ "url": "git+https://github.com/usagekit/usagekit.git",
37
37
  "directory": "packages/meter"
38
38
  },
39
39
  "homepage": "https://usagekit.dev",
40
40
  "bugs": {
41
- "url": "https://github.com/msniezynski/usagekit/issues"
41
+ "url": "https://github.com/usagekit/usagekit/issues"
42
42
  }
43
43
  }