@usagekit/meter 0.1.0 → 0.4.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/access.d.ts CHANGED
@@ -5,4 +5,15 @@ export type OwnershipResolver = (scope: Extract<BudgetScope, {
5
5
  }>) => Promise<BudgetOwner | null>;
6
6
  export declare const includes: (list: readonly string[] | "*", id: string) => boolean;
7
7
  export declare function canRead(access: AccessContext, scope: UsageScope): boolean;
8
+ /** Pools and tags span principals: their figures are shared, so unreadable ones are redacted, not forbidden. */
9
+ export declare const isShared: (scope: BudgetScope) => scope is {
10
+ kind: "platform_pool";
11
+ namespace: string;
12
+ poolId: string;
13
+ } | {
14
+ kind: "tag";
15
+ namespace: string;
16
+ tag: string;
17
+ };
18
+ export declare function canReadShared(access: AccessContext, scope: BudgetScope): boolean;
8
19
  export declare function canReadBudget(resolveOwnership: OwnershipResolver | undefined, access: AccessContext, scope: BudgetScope): Promise<boolean>;
package/dist/access.js CHANGED
@@ -13,9 +13,20 @@ export function canRead(access, scope) {
13
13
  return access.canManageBudgets || includes(access.readablePools, scope.poolId);
14
14
  }
15
15
  }
16
+ /** Pools and tags span principals: their figures are shared, so unreadable ones are redacted, not forbidden. */
17
+ export const isShared = (scope) => scope.kind === "platform_pool" || scope.kind === "tag";
18
+ export function canReadShared(access, scope) {
19
+ if (access.namespace !== scope.namespace)
20
+ return false;
21
+ if (scope.kind === "platform_pool")
22
+ return canRead(access, scope);
23
+ return access.canManageBudgets || access.readablePrincipals === "*";
24
+ }
16
25
  export async function canReadBudget(resolveOwnership, access, scope) {
17
26
  if (access.namespace !== scope.namespace)
18
27
  return false;
28
+ if (isShared(scope))
29
+ return canReadShared(access, scope);
19
30
  if (scope.kind !== "connection" && scope.kind !== "access_credential")
20
31
  return canRead(access, scope);
21
32
  const owner = await resolveOwnership?.(scope);
@@ -0,0 +1,15 @@
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 = {
6
+ kind: "reserve";
7
+ input: ReserveInput;
8
+ } | {
9
+ kind: "count";
10
+ state: "passthrough" | "unpriced";
11
+ } | {
12
+ kind: "invalid";
13
+ failure: ValidationFailure;
14
+ };
15
+ export declare function resolveReserve(requested: MeterReserveInput, existing: Operation | null, catalog?: PricingCatalog, resolveConnection?: ConnectionResolver): Promise<ReserveResolution>;
@@ -0,0 +1,62 @@
1
+ const invalid = (field, reason) => ({
2
+ kind: "invalid",
3
+ failure: { outcome: "invalid", field, reason },
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
+ const provenanceOf = (estimateSource, providerPriceVersion) => ({
12
+ ...(estimateSource ? { estimateSource } : {}),
13
+ ...(providerPriceVersion ? { providerPriceVersion } : {}),
14
+ });
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
+ };
29
+ return {
30
+ kind: "reserve",
31
+ input: { ...rest, estimate, ...provenanceOf(estimateSource, providerPriceVersion) },
32
+ };
33
+ }
34
+ const policy = catalog && resolveConnection ? await resolveConnection(rest.scope.connection) : undefined;
35
+ if (policy && policy.provider !== rest.provider)
36
+ return invalid("provider", "differs from the connection provider");
37
+ if (policy && catalog) {
38
+ const known = catalog.operationsOf(policy.provider).find((o) => o.id === rest.operation);
39
+ if (policy.tracking?.[rest.operation] === "passthrough" || known?.billable === false)
40
+ return { kind: "count", state: "passthrough" };
41
+ if (estimate === undefined) {
42
+ if (!known)
43
+ 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
+ };
54
+ }
55
+ }
56
+ if (estimate === undefined)
57
+ return invalid("estimate", catalog && resolveConnection ? "unknown connection" : "required without a catalog");
58
+ return {
59
+ kind: "reserve",
60
+ input: { ...rest, estimate, ...provenanceOf(estimateSource, providerPriceVersion) },
61
+ };
62
+ }
package/dist/index.d.ts CHANGED
@@ -3,3 +3,5 @@ export { createMeter } from "./meter.js";
3
3
  export { defaultPolicy } from "./policy.js";
4
4
  export type { MeterPolicy } from "./policy.js";
5
5
  export type { OwnershipResolver } from "./access.js";
6
+ export { resolveReserve } from "./catalog.js";
7
+ export type { ConnectionResolver, ReserveResolution } from "./catalog.js";
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { createMeter } from "./meter.js";
2
2
  export { defaultPolicy } from "./policy.js";
3
+ export { resolveReserve } from "./catalog.js";
package/dist/meter.d.ts CHANGED
@@ -1,9 +1,13 @@
1
- import type { Meter } from "@usagekit/core";
1
+ import type { Meter, PricingCatalog } from "@usagekit/core";
2
+ import type { ConnectionResolver } from "./catalog.js";
2
3
  import type { Store, Clock } from "@usagekit/store";
3
4
  import type { MeterPolicy } from "./policy.js";
4
- export declare function createMeter({ store, policy, clock, resolveOwnership, }: {
5
+ export declare function createMeter({ store, policy, clock, resolveOwnership, catalog, resolveConnection, }: {
5
6
  store: Store;
6
7
  policy?: MeterPolicy;
7
8
  clock: Clock;
8
9
  resolveOwnership?: import("./access.js").OwnershipResolver;
10
+ /** With resolveConnection, lets reserve omit estimate and applies tracking policy. */
11
+ catalog?: PricingCatalog;
12
+ resolveConnection?: ConnectionResolver;
9
13
  }): Meter;
package/dist/meter.js CHANGED
@@ -1,13 +1,15 @@
1
+ import { resolveReserve } from "./catalog.js";
1
2
  import { InvalidInput } from "@usagekit/store";
2
3
  import { defaultPolicy } from "./policy.js";
3
- import { validate, reserveValidation, usageValidation } from "./validation.js";
4
- import { canRead, canReadBudget } from "./access.js";
5
- export function createMeter({ store, policy = defaultPolicy, clock, resolveOwnership, }) {
4
+ import { validate, reserveValidation, usageValidation, operationsValidation, sourceValidation, provenanceValidation, requestCountsValidation, } from "./validation.js";
5
+ import { canRead, canReadBudget, canReadShared, isShared } from "./access.js";
6
+ import { defaultBudgetOrder, normalizeTags } from "@usagekit/core";
7
+ export function createMeter({ store, policy = defaultPolicy, clock, resolveOwnership, catalog, resolveConnection, }) {
6
8
  if (!Number.isFinite(clock.now().getTime()))
7
9
  throw new Error("InvalidInput: clock");
8
- if (policy.budgetOrder.length !== 5 ||
9
- new Set(policy.budgetOrder).size !== 5 ||
10
- policy.budgetOrder.some((k) => !defaultPolicy.budgetOrder.includes(k)))
10
+ if (policy.budgetOrder.length !== defaultBudgetOrder.length ||
11
+ new Set(policy.budgetOrder).size !== defaultBudgetOrder.length ||
12
+ policy.budgetOrder.some((k) => !defaultBudgetOrder.includes(k)))
11
13
  throw new Error("InvalidInput: budgetOrder");
12
14
  async function safely(fn) {
13
15
  try {
@@ -29,21 +31,54 @@ export function createMeter({ store, policy = defaultPolicy, clock, resolveOwner
29
31
  const read = async (fn) => safely(async () => ({ outcome: "ok", value: await fn() }));
30
32
  return {
31
33
  expireReservations: (i) => command(i, () => store.expireReservations(i)),
32
- reserve: async (i) => {
33
- const invalid = reserveValidation(i);
34
- if (invalid)
35
- return invalid;
34
+ reserve: async (requested) => {
35
+ const early = validate({ ...requested, estimate: requested.estimate ?? [] }) ??
36
+ sourceValidation(requested) ??
37
+ provenanceValidation(requested);
38
+ if (early)
39
+ return early;
36
40
  return safely(async () => {
37
41
  const existing = await store.getOperation({
38
- namespace: i.scope.namespace,
39
- principal: i.scope.principal,
40
- operationId: i.operationId,
42
+ namespace: requested.scope.namespace,
43
+ principal: requested.scope.principal,
44
+ operationId: requested.operationId,
41
45
  });
46
+ const resolution = await resolveReserve(requested, existing, catalog, resolveConnection);
47
+ if (resolution.kind === "invalid")
48
+ return resolution.failure;
49
+ if (resolution.kind === "count") {
50
+ const { scope, surface, source, provider, operation, platformPools } = requested;
51
+ const counted = await store.countRequest({
52
+ commandId: requested.operationId,
53
+ scope,
54
+ surface,
55
+ source,
56
+ provider,
57
+ operation,
58
+ state: resolution.state,
59
+ ...(platformPools ? { platformPools } : {}),
60
+ });
61
+ if (counted.outcome === "conflict")
62
+ return {
63
+ outcome: "invalid",
64
+ field: "operationId",
65
+ reason: "reused for a different counted request",
66
+ };
67
+ if (counted.outcome === "invalid")
68
+ return counted;
69
+ return { outcome: resolution.state, replayed: counted.replayed, operation: null };
70
+ }
71
+ const raw = resolution.input, invalid = reserveValidation(raw);
72
+ if (invalid)
73
+ return invalid;
74
+ // Tags are attribution: sorted here so every adapter stores one canonical order.
75
+ const tags = raw.scope.tags === undefined ? undefined : normalizeTags(raw.scope.tags), i = tags ? { ...raw, scope: { ...raw.scope, tags } } : raw;
42
76
  if (existing)
43
77
  return store.reserve(i, { budgetOrder: policy.budgetOrder });
44
78
  const statuses = await store.applicableBudgets({
45
79
  scope: i.scope,
46
80
  surface: i.surface,
81
+ source: i.source,
47
82
  units: i.estimate.map((q) => q.unit),
48
83
  ...(i.platformPools ? { platformPools: i.platformPools } : {}),
49
84
  });
@@ -64,6 +99,18 @@ export function createMeter({ store, policy = defaultPolicy, clock, resolveOwner
64
99
  settle: (i) => command(i, () => store.settle(i)),
65
100
  correct: (i) => command(i, () => store.correct(i)),
66
101
  releaseUndispatched: (i) => command(i, () => store.releaseUndispatched(i)),
102
+ countRequest: (i) => {
103
+ const invalid = validate(i) ?? sourceValidation(i);
104
+ return invalid ? Promise.resolve(invalid) : safely(() => store.countRequest(i));
105
+ },
106
+ requestCounts: async (access, q) => {
107
+ const invalid = requestCountsValidation(q);
108
+ if (invalid)
109
+ return invalid;
110
+ if (!access.canReadBillingDetail || !canRead(access, q.scope))
111
+ return { outcome: "forbidden" };
112
+ return read(() => store.requestCounts(q));
113
+ },
67
114
  getOperation: async (access, i) => {
68
115
  const invalid = validate(i);
69
116
  if (invalid)
@@ -81,6 +128,14 @@ export function createMeter({ store, policy = defaultPolicy, clock, resolveOwner
81
128
  return { outcome: "forbidden" };
82
129
  return read(() => store.aggregate(q));
83
130
  },
131
+ listOperations: async (access, q) => {
132
+ const invalid = operationsValidation(q);
133
+ if (invalid)
134
+ return invalid;
135
+ if (!access.canReadBillingDetail || !canRead(access, q.scope))
136
+ return { outcome: "forbidden" };
137
+ return read(() => store.listOperations(q));
138
+ },
84
139
  definedBudgets: async (access, q) => {
85
140
  const invalid = validate(q);
86
141
  if (invalid)
@@ -90,7 +145,7 @@ export function createMeter({ store, policy = defaultPolicy, clock, resolveOwner
90
145
  return read(() => store.definedBudgets(q));
91
146
  },
92
147
  applicableBudgets: async (access, q) => {
93
- const invalid = validate(q);
148
+ const invalid = validate(q) ?? sourceValidation(q);
94
149
  if (invalid)
95
150
  return invalid;
96
151
  if (!canRead(access, {
@@ -102,13 +157,13 @@ export function createMeter({ store, policy = defaultPolicy, clock, resolveOwner
102
157
  return safely(async () => {
103
158
  const rows = await store.applicableBudgets(q);
104
159
  for (const status of rows) {
105
- if (status.budget.scope.kind !== "platform_pool" &&
160
+ if (!isShared(status.budget.scope) &&
106
161
  !(await canReadBudget(resolveOwnership, access, status.budget.scope)))
107
162
  return { outcome: "forbidden" };
108
163
  }
109
164
  return {
110
165
  outcome: "ok",
111
- value: rows.map((s) => s.budget.scope.kind === "platform_pool" && !canRead(access, s.budget.scope)
166
+ value: rows.map((s) => isShared(s.budget.scope) && !canReadShared(access, s.budget.scope)
112
167
  ? {
113
168
  ...s,
114
169
  budget: { ...s.budget, limit: null },
@@ -1,4 +1,12 @@
1
- import type { ValidationFailure, ReserveInput, UsageQuery } from "@usagekit/core";
1
+ import type { ValidationFailure, ReserveInput, UsageQuery, ApplicableBudgetsQuery, OperationsQuery, RequestCountsQuery } from "@usagekit/core";
2
2
  export declare function validate(value: unknown, field?: string): ValidationFailure | null;
3
+ /** The host sets both fields at authentication; a source outside its surface group is a forgery or a bug. */
4
+ export declare function sourceValidation(i: Pick<ApplicableBudgetsQuery, "surface" | "source">): ValidationFailure | null;
3
5
  export declare function reserveValidation(i: ReserveInput): ValidationFailure | null;
6
+ export declare function operationsValidation(q: OperationsQuery): ValidationFailure | null;
4
7
  export declare function usageValidation(q: UsageQuery): ValidationFailure | null;
8
+ /** Hosts may name the source; a value outside the catalog order is rejected before Store. */
9
+ export declare function provenanceValidation(i: {
10
+ estimateSource?: string;
11
+ }): ValidationFailure | null;
12
+ export declare function requestCountsValidation(q: RequestCountsQuery): ValidationFailure | null;
@@ -1,3 +1,4 @@
1
+ import { sourcesOf, normalizeTags } from "@usagekit/core";
1
2
  export function validate(value, field = "input") {
2
3
  const fail = (reason) => ({ outcome: "invalid", field, reason });
3
4
  if (typeof value === "string" && !value.trim())
@@ -22,24 +23,49 @@ export function validate(value, field = "input") {
22
23
  }
23
24
  return null;
24
25
  }
26
+ /** The host sets both fields at authentication; a source outside its surface group is a forgery or a bug. */
27
+ export function sourceValidation(i) {
28
+ if (i.source !== undefined && !sourcesOf(i.surface)?.includes(i.source))
29
+ return { outcome: "invalid", field: "source", reason: "source outside surface group" };
30
+ return null;
31
+ }
25
32
  export function reserveValidation(i) {
26
- const error = validate(i);
33
+ const error = validate(i) ?? sourceValidation(i);
27
34
  if (error)
28
35
  return error;
36
+ if (i.scope.tags !== undefined && !normalizeTags(i.scope.tags))
37
+ return {
38
+ outcome: "invalid",
39
+ field: "scope.tags",
40
+ reason: "1 to 16 unique tags matching the tag pattern",
41
+ };
29
42
  if (new Set(i.estimate.map((q) => q.unit)).size !== i.estimate.length)
30
43
  return { outcome: "invalid", field: "estimate", reason: "duplicate unit" };
31
44
  return null;
32
45
  }
33
- export function usageValidation(q) {
34
- const error = validate(q);
35
- if (error)
36
- return error;
46
+ const lifecycle = ["reserved", "dispatch_intended", "pending", "settled", "released"];
47
+ function windowValidation(q) {
37
48
  if (!Number.isFinite(Date.parse(q.from)) ||
38
49
  !Number.isFinite(Date.parse(q.to)) ||
39
50
  Date.parse(q.from) > Date.parse(q.to))
40
51
  return { outcome: "invalid", field: "window", reason: "invalid time interval" };
41
52
  if (q.limit !== undefined && (q.limit < 1 || q.limit > 1000))
42
53
  return { outcome: "invalid", field: "limit", reason: "must be between 1 and 1000" };
54
+ return null;
55
+ }
56
+ export function operationsValidation(q) {
57
+ const error = validate(q) ?? windowValidation(q);
58
+ if (error)
59
+ return error;
60
+ if (q.states !== undefined &&
61
+ (q.states.length < 1 || q.states.some((state) => !lifecycle.includes(state))))
62
+ return { outcome: "invalid", field: "states", reason: "one or more lifecycle states" };
63
+ return null;
64
+ }
65
+ export function usageValidation(q) {
66
+ const error = validate(q) ?? windowValidation(q);
67
+ if (error)
68
+ return error;
43
69
  if (q.groupBy.includes("principal") && !["namespace", "group"].includes(q.scope.kind))
44
70
  return {
45
71
  outcome: "invalid",
@@ -48,3 +74,14 @@ export function usageValidation(q) {
48
74
  };
49
75
  return null;
50
76
  }
77
+ const estimateSources = ["manual", "measured", "list", "unknown"];
78
+ /** Hosts may name the source; a value outside the catalog order is rejected before Store. */
79
+ export function provenanceValidation(i) {
80
+ if (i.estimateSource !== undefined &&
81
+ !estimateSources.includes(i.estimateSource))
82
+ return { outcome: "invalid", field: "estimateSource", reason: "unknown estimate source" };
83
+ return null;
84
+ }
85
+ export function requestCountsValidation(q) {
86
+ return validate(q) ?? windowValidation(q);
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagekit/meter",
3
- "version": "0.1.0",
3
+ "version": "0.4.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.1.0",
27
- "@usagekit/store": "0.1.0"
26
+ "@usagekit/core": "0.4.0",
27
+ "@usagekit/store": "0.4.0"
28
28
  },
29
29
  "sideEffects": false,
30
30
  "publishConfig": {