@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 +21 -6
- package/dist/catalog.js +92 -37
- package/dist/meter.js +87 -4
- package/dist/validation.d.ts +3 -1
- package/dist/validation.js +36 -0
- package/package.json +5 -5
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
|
|
3
|
-
export type ConnectionResolver = (
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
16
|
-
|
|
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
|
-
|
|
31
|
-
|
|
12
|
+
outcome: "invalid",
|
|
13
|
+
field: "fundingSource",
|
|
14
|
+
reason: "missing from the resolved connection policy",
|
|
32
15
|
};
|
|
33
|
-
|
|
34
|
-
|
|
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 (
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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: {
|
|
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
|
-
|
|
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?.(
|
|
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
|
}
|
package/dist/validation.d.ts
CHANGED
|
@@ -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;
|
package/dist/validation.js
CHANGED
|
@@ -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.
|
|
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.
|
|
27
|
-
"@usagekit/store": "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/
|
|
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/
|
|
41
|
+
"url": "https://github.com/usagekit/usagekit/issues"
|
|
42
42
|
}
|
|
43
43
|
}
|