@stacksjs/hq 0.0.0-stage → 0.75.542
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/LICENSE.md +21 -0
- package/README.md +171 -2
- package/dist/client.d.ts +135 -0
- package/dist/client.js +1 -0
- package/dist/connect-codes.d.ts +40 -0
- package/dist/connect-codes.js +1 -0
- package/dist/counter.d.ts +53 -0
- package/dist/counter.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/meter.d.ts +7 -0
- package/dist/meter.js +1 -0
- package/dist/period.d.ts +3 -0
- package/dist/period.js +1 -0
- package/dist/policy.d.ts +66 -0
- package/dist/policy.js +1 -0
- package/dist/reservation.d.ts +23 -0
- package/dist/reservation.js +1 -0
- package/dist/service.d.ts +83 -0
- package/dist/service.js +1 -0
- package/dist/sql.d.ts +16 -0
- package/dist/sql.js +1 -0
- package/dist/testing.d.ts +30 -0
- package/dist/testing.js +1 -0
- package/dist/tsconfig.json +5 -0
- package/dist/types.d.ts +29 -0
- package/dist/types.js +0 -0
- package/dist/usage-counters.d.ts +27 -0
- package/dist/usage-counters.js +1 -0
- package/dist/usage-header.d.ts +22 -0
- package/dist/usage-header.js +1 -0
- package/package.json +61 -4
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Open Web Foundation
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,172 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @stacksjs/hq
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
What every Stacks HQ service does the same way: counting usage against a plan
|
|
4
|
+
allowance, the connect and usage endpoints, and the typed readers an app uses to
|
|
5
|
+
call a service.
|
|
6
|
+
|
|
7
|
+
The services stay separate. BugHQ, LogHQ, AnalyticsHQ, StatusHQ and CommsHQ each
|
|
8
|
+
keep their own accounts, keys, plans and billing; there is no central account
|
|
9
|
+
and no shared login. This package shares code, never identity.
|
|
10
|
+
|
|
11
|
+
It has no runtime dependencies. A database client or a store is always passed
|
|
12
|
+
in, so the same code runs in a service, in a test and in an app.
|
|
13
|
+
|
|
14
|
+
| Entrypoint | For |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `@stacksjs/hq/meter` | a service counting usage and deciding what to accept |
|
|
17
|
+
| `@stacksjs/hq/service` | a service mounting the connect and usage endpoints |
|
|
18
|
+
| `@stacksjs/hq/client` | an app reading a service with its read key |
|
|
19
|
+
| `@stacksjs/hq/testing` | the contract suite and an in-memory service |
|
|
20
|
+
|
|
21
|
+
## Meter
|
|
22
|
+
|
|
23
|
+
### Counter (ingest)
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createCounter, formatUsageHeader, sqlCounterStore, USAGE_HEADER } from '@stacksjs/hq/meter'
|
|
27
|
+
|
|
28
|
+
const counter = createCounter({ store: sqlCounterStore(db, { dialect: 'postgres' }) })
|
|
29
|
+
|
|
30
|
+
route.post('/errors', async (request) => {
|
|
31
|
+
const admission = counter.admit(project.owner_id, 'errors', limits.errors)
|
|
32
|
+
if (admission.accept)
|
|
33
|
+
await store(event)
|
|
34
|
+
// Always 2xx. A refusal would only make the SDK retry.
|
|
35
|
+
return new Response(null, {
|
|
36
|
+
status: 202,
|
|
37
|
+
headers: { [USAGE_HEADER]: formatUsageHeader({ ...admission, upgradeUrl }) },
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`record` and `admit` touch memory and return; they never await and never query.
|
|
43
|
+
A timer folds the buffered deltas into `usage_counters` every ten seconds with an
|
|
44
|
+
additive upsert, so any number of instances converge on one total. A crash loses
|
|
45
|
+
at most one interval of counts.
|
|
46
|
+
|
|
47
|
+
The table is keyed by `(owner, meter, period)`, the period being `YYYY-MM` in
|
|
48
|
+
UTC. Put `usageCountersMigrationSql({ dialect })` in a migration, or call
|
|
49
|
+
`ensureUsageCountersTable(db)`. Nothing is created implicitly.
|
|
50
|
+
|
|
51
|
+
`admit` counts a kept event under `meter` and a dropped one under
|
|
52
|
+
`droppedMeter(meter)` (`errors.dropped`), so the reported usage is what was
|
|
53
|
+
stored while the one-in-ten sequence still advances on every event received.
|
|
54
|
+
|
|
55
|
+
### Reservation (sends)
|
|
56
|
+
|
|
57
|
+
`reserveUsage(store, meter, quantity)` and `releaseUsage(store, reservation)`
|
|
58
|
+
are a compare-and-set over a `UsageQuotaStore` you implement with `read` and
|
|
59
|
+
`compareAndSet`. They never overbook under concurrency and fail closed with
|
|
60
|
+
`UsageQuotaError`. To give a send its grace, store `graceCeiling(limit,
|
|
61
|
+
HARD_SEND)` as the snapshot's limit.
|
|
62
|
+
|
|
63
|
+
### Policy
|
|
64
|
+
|
|
65
|
+
`evaluate(used, limit, policy)` is pure. `used` includes the unit being decided.
|
|
66
|
+
|
|
67
|
+
| Policy | Up to the limit | Limit to 120% | Past 120% |
|
|
68
|
+
|---|---|---|---|
|
|
69
|
+
| `SOFT_INGEST` | `ok`, then `warn` from 80% | `over`, accepted | `sampled`, one in ten accepted |
|
|
70
|
+
| `HARD_SEND` | `ok`, then `warn` from 80% | `over`, accepted | `over`, refused |
|
|
71
|
+
|
|
72
|
+
`enforceQuota(meter, used, limit)` throws `QuotaExceededError` (code
|
|
73
|
+
`quota_exceeded`, with `meter`, `used`, `limit`, `upgradeUrl`) when a send is
|
|
74
|
+
refused. A `null` limit is unlimited.
|
|
75
|
+
|
|
76
|
+
### The usage header
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
X-HQ-Usage: errors;412;500;warn;upgrade=https%3A%2F%2Fbughq.example%2Fpricing
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`meter;used;limit;state;upgrade=<url>`. The limit is `unlimited` for an
|
|
83
|
+
unlimited meter and the URL is percent-encoded. `parseUsageHeader` answers
|
|
84
|
+
`null` for anything malformed.
|
|
85
|
+
|
|
86
|
+
## Service
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
// routes/hq.ts
|
|
90
|
+
import { createHqService, sqlConnectCodeStore } from '@stacksjs/hq/service'
|
|
91
|
+
|
|
92
|
+
const hq = createHqService({
|
|
93
|
+
service: 'bughq',
|
|
94
|
+
approveUrl: code => `https://bughq.example/cli/connect?code=${code}`,
|
|
95
|
+
upgradeUrl: 'https://bughq.example/pricing',
|
|
96
|
+
codes: sqlConnectCodeStore(db, { dialect: 'postgres' }),
|
|
97
|
+
provision: (user, { app }) => createOrReuseProject(user, app),
|
|
98
|
+
authenticateReadKey: key => principalForReadKey(key),
|
|
99
|
+
usage: principal => [{ meter: 'errors', used: usedBy(principal.owner), limit: limitFor(principal.owner) }],
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
for (const { method, path, handler } of hq.routes)
|
|
103
|
+
route[method.toLowerCase()](path, handler).skipCsrf()
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
| Route | Answer |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `POST /api/cli/connect` | `201 { code, approveUrl }` |
|
|
109
|
+
| `GET /api/cli/connect/{code}` | `200 { status: 'pending' }`; once approved `200 { status: 'approved', project, ingestKey, readKey, dashboardUrl }`, exactly once; otherwise `404 { status: 'expired' }` |
|
|
110
|
+
| `GET /api/v1/usage` | `200 { meter, used, limit, period, state, upgradeUrl }[]`; `401` without a valid `Authorization: Bearer <read key>` |
|
|
111
|
+
|
|
112
|
+
The approve page is the service's own. It calls `hq.describeConnect(code)` to
|
|
113
|
+
show which app is asking and `hq.approveConnect(code, user)` when the signed-in
|
|
114
|
+
user confirms.
|
|
115
|
+
|
|
116
|
+
Codes are single-use and live ten minutes (`codeTtlMs`). A store only ever sees
|
|
117
|
+
the SHA-256 of a code, and approved keys wait encrypted under a key derived from
|
|
118
|
+
the code itself, so a copy of the table yields neither a code that can be polled
|
|
119
|
+
nor a key. `connectCodesMigrationSql()` is the table for `sqlConnectCodeStore`.
|
|
120
|
+
|
|
121
|
+
The handlers do not rate-limit. Put a limit on `POST /api/cli/connect` in the
|
|
122
|
+
router.
|
|
123
|
+
|
|
124
|
+
### Contract suite
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { hqServiceContract } from '@stacksjs/hq/testing'
|
|
128
|
+
|
|
129
|
+
hqServiceContract('bughq', async () => ({ adapter, user: await signedInUser() }))
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
It covers code issue, hashing at rest, pending, approval, single use under
|
|
133
|
+
concurrent polls, expiry, project reuse, read-key refusal (including the ingest
|
|
134
|
+
key) and the usage shape. `createMemoryHqAdapter()` passes it and doubles as a
|
|
135
|
+
stand-in service for an app's tests.
|
|
136
|
+
|
|
137
|
+
## Client
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { createHqClient } from '@stacksjs/hq/client'
|
|
141
|
+
|
|
142
|
+
const hq = createHqClient({
|
|
143
|
+
services: {
|
|
144
|
+
bughq: { url: env.BUGHQ_URL, readKey: env.BUGHQ_READ_KEY, project: env.BUGHQ_PROJECT },
|
|
145
|
+
loghq: { url: env.LOGHQ_URL, readKey: env.LOGHQ_READ_KEY, project: env.LOGHQ_PROJECT },
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const usage = await hq.getUsage('bughq')
|
|
150
|
+
if (usage.ok)
|
|
151
|
+
usage.data // UsageReport[]
|
|
152
|
+
else
|
|
153
|
+
usage.reason // 'not_configured' | 'unauthorized' | 'timeout' | 'network' | 'http' | 'bad_response'
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
A reader never throws into the caller. A service that is down, slow (three
|
|
157
|
+
seconds by default) or not configured costs the page one panel.
|
|
158
|
+
|
|
159
|
+
### Readers and the endpoints behind them
|
|
160
|
+
|
|
161
|
+
| Reader | Status | Endpoint |
|
|
162
|
+
|---|---|---|
|
|
163
|
+
| `getUsage(service)` | implemented | `GET /api/v1/usage`, mounted from `@stacksjs/hq/service` |
|
|
164
|
+
| `logs.list(query)` | implemented | LogHQ `GET /api/projects/{project}/logs` with `level`, `channel`, `environment`, `trace`, `request`, `q`, `before`, `limit`; answers `{ logs, nextCursor }` |
|
|
165
|
+
| `issues.list(query)` | implemented | BugHQ `GET /api/projects/{project}/issues` with `status`; answers `{ issues }` |
|
|
166
|
+
| `AnalyticsReader.summary(query)` | interface only | AnalyticsHQ has no read-key endpoint. It needs `GET /api/v1/sites/{site}/summary?from&to` answering `{ pageviews, visitors, topPages: { path, pageviews }[] }` |
|
|
167
|
+
| `UptimeReader.monitors()` | interface only | StatusHQ has no read-key endpoint. It needs `GET /api/v1/monitors` answering `{ monitors: { id, name, url, status, uptimePercent, lastCheckedAt }[] }` |
|
|
168
|
+
| `CommsReader.summary()` | interface only | CommsHQ has no read-key endpoint. It needs `GET /api/v1/summary` answering `{ sent, delivered, bounced, contacts }` for the current period |
|
|
169
|
+
|
|
170
|
+
Both implemented project readers accept the read key as a bearer token and
|
|
171
|
+
answer `404` (not `401`) for a key that does not open the project, which the
|
|
172
|
+
client reports as `reason: 'http'` with `status: 404`.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { UsageReport, UsageState } from './types';
|
|
2
|
+
export type { UsageReport, UsageState } from './types';
|
|
3
|
+
export type { UsageHeader } from './usage-header';
|
|
4
|
+
export declare function createHqClient(options: HqClientOptions): HqClient;
|
|
5
|
+
export declare interface HqServiceConfig {
|
|
6
|
+
url: string
|
|
7
|
+
readKey: string
|
|
8
|
+
project?: string
|
|
9
|
+
}
|
|
10
|
+
export declare interface HqClientOptions {
|
|
11
|
+
services: Partial<Record<HqServiceName, HqServiceConfig | null | undefined>>
|
|
12
|
+
timeoutMs?: number
|
|
13
|
+
fetch?: (input: string, init?: RequestInit) => Promise<Response>
|
|
14
|
+
}
|
|
15
|
+
export declare interface HqUnavailable {
|
|
16
|
+
ok: false
|
|
17
|
+
unavailable: true
|
|
18
|
+
service: HqServiceName
|
|
19
|
+
reason: HqUnavailableReason
|
|
20
|
+
status?: number
|
|
21
|
+
message: string
|
|
22
|
+
}
|
|
23
|
+
/** LogHQ `GET /api/projects/{project}/logs`. */
|
|
24
|
+
export declare interface LogQuery {
|
|
25
|
+
level?: string | string[]
|
|
26
|
+
channel?: string
|
|
27
|
+
environment?: string
|
|
28
|
+
trace?: string
|
|
29
|
+
request?: string
|
|
30
|
+
search?: string
|
|
31
|
+
before?: string
|
|
32
|
+
limit?: number
|
|
33
|
+
}
|
|
34
|
+
export declare interface LogEntry {
|
|
35
|
+
id: string
|
|
36
|
+
level: string
|
|
37
|
+
message: string
|
|
38
|
+
channel: string | null
|
|
39
|
+
environment: string | null
|
|
40
|
+
release: string | null
|
|
41
|
+
framework: string | null
|
|
42
|
+
host: string | null
|
|
43
|
+
timestamp: string
|
|
44
|
+
createdAt: string | null
|
|
45
|
+
traceId: string | null
|
|
46
|
+
requestId: string | null
|
|
47
|
+
}
|
|
48
|
+
export declare interface LogPage {
|
|
49
|
+
logs: LogEntry[]
|
|
50
|
+
nextCursor: string | null
|
|
51
|
+
}
|
|
52
|
+
export declare interface LogReader {
|
|
53
|
+
list: (query?: LogQuery) => Promise<HqResult<LogPage>>
|
|
54
|
+
}
|
|
55
|
+
/** BugHQ `GET /api/projects/{project}/issues`. */
|
|
56
|
+
export declare interface IssueQuery {
|
|
57
|
+
status?: string
|
|
58
|
+
}
|
|
59
|
+
export declare interface Issue {
|
|
60
|
+
id: string
|
|
61
|
+
title: string
|
|
62
|
+
culprit: string | null
|
|
63
|
+
errorType: string | null
|
|
64
|
+
level: string | null
|
|
65
|
+
status: string
|
|
66
|
+
count: number
|
|
67
|
+
firstSeen: string | null
|
|
68
|
+
lastSeen: string | null
|
|
69
|
+
}
|
|
70
|
+
export declare interface IssueReader {
|
|
71
|
+
list: (query?: IssueQuery) => Promise<HqResult<Issue[]>>
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The three readers below are contracts only. No service exposes a read-key
|
|
75
|
+
* endpoint for them yet; the package README lists what each must add.
|
|
76
|
+
*/
|
|
77
|
+
export declare interface AnalyticsQuery {
|
|
78
|
+
from?: string
|
|
79
|
+
to?: string
|
|
80
|
+
}
|
|
81
|
+
export declare interface AnalyticsSummary {
|
|
82
|
+
pageviews: number
|
|
83
|
+
visitors: number
|
|
84
|
+
topPages: { path: string, pageviews: number }[]
|
|
85
|
+
}
|
|
86
|
+
export declare interface AnalyticsReader {
|
|
87
|
+
summary: (query?: AnalyticsQuery) => Promise<HqResult<AnalyticsSummary>>
|
|
88
|
+
}
|
|
89
|
+
export declare interface UptimeMonitor {
|
|
90
|
+
id: string
|
|
91
|
+
name: string
|
|
92
|
+
url: string | null
|
|
93
|
+
status: 'up' | 'down' | 'paused' | 'unknown'
|
|
94
|
+
uptimePercent: number | null
|
|
95
|
+
lastCheckedAt: string | null
|
|
96
|
+
}
|
|
97
|
+
export declare interface UptimeReader {
|
|
98
|
+
monitors: () => Promise<HqResult<UptimeMonitor[]>>
|
|
99
|
+
}
|
|
100
|
+
export declare interface CommsSummary {
|
|
101
|
+
sent: number
|
|
102
|
+
delivered: number
|
|
103
|
+
bounced: number
|
|
104
|
+
contacts: number
|
|
105
|
+
}
|
|
106
|
+
export declare interface CommsReader {
|
|
107
|
+
summary: () => Promise<HqResult<CommsSummary>>
|
|
108
|
+
}
|
|
109
|
+
export declare interface HqClient {
|
|
110
|
+
getUsage: (service: HqServiceName) => Promise<HqResult<UsageReport[]>>
|
|
111
|
+
logs: LogReader
|
|
112
|
+
issues: IssueReader
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Typed readers an app uses to call the HQ services it connected to, each
|
|
116
|
+
* with that service's own read key.
|
|
117
|
+
*
|
|
118
|
+
* One rule governs everything here: a reader NEVER throws into the caller.
|
|
119
|
+
* These run while an app renders its own dashboard, and a service being slow,
|
|
120
|
+
* down or misconfigured must cost that page a panel, not the page. Every
|
|
121
|
+
* failure is a typed `HqUnavailable` value.
|
|
122
|
+
*/
|
|
123
|
+
export type HqServiceName = 'bughq' | 'loghq' | 'analyticshq' | 'statushq' | 'commshq';
|
|
124
|
+
export type HqUnavailableReason = | 'not_configured'
|
|
125
|
+
/** The read key was refused. */
|
|
126
|
+
| 'unauthorized'
|
|
127
|
+
| 'timeout'
|
|
128
|
+
| 'network'
|
|
129
|
+
/** The service answered with a status that is neither 2xx nor an auth refusal. */
|
|
130
|
+
| 'http'
|
|
131
|
+
/** A 2xx whose body was not the documented shape. */
|
|
132
|
+
| 'bad_response';
|
|
133
|
+
export type HqResult<T> = { ok: true, data: T } | HqUnavailable;
|
|
134
|
+
declare type Row = Record<string, unknown>;
|
|
135
|
+
export { parseUsageHeader, USAGE_HEADER } from './usage-header';
|
package/dist/client.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{isPeriod}from"./period";export{parseUsageHeader,USAGE_HEADER}from"./usage-header";const STATES=new Set(["ok","warn","over","sampled"]);function isRow(value){return typeof value==="object"&&value!==null&&!Array.isArray(value)}function text(value){if(value instanceof Date)return value.toISOString();return value===null||value===undefined?null:String(value)}function toUsageReport(value){if(!isRow(value)||typeof value.meter!=="string"||typeof value.used!=="number"||!Number.isFinite(value.used))return null;if(!(value.limit===null||typeof value.limit==="number"&&Number.isFinite(value.limit)))return null;if(typeof value.state!=="string"||!STATES.has(value.state)||!isPeriod(value.period)||typeof value.upgradeUrl!=="string")return null;return{meter:value.meter,used:value.used,limit:value.limit,period:value.period,state:value.state,upgradeUrl:value.upgradeUrl}}function toLogEntry(value){if(!isRow(value)||value.id==null||value.message==null)return null;return{id:String(value.id),level:text(value.level)??"info",message:String(value.message),channel:text(value.channel),environment:text(value.environment),release:text(value.release),framework:text(value.framework),host:text(value.host),timestamp:text(value.timestamp)??"",createdAt:text(value.created_at),traceId:text(value.trace_id),requestId:text(value.request_id)}}function toIssue(value){if(!isRow(value)||value.id==null)return null;return{id:String(value.id),title:text(value.title)??"",culprit:text(value.culprit),errorType:text(value.error_type),level:text(value.level),status:text(value.status)??"unresolved",count:Number(value.count??0)||0,firstSeen:text(value.first_seen),lastSeen:text(value.last_seen)}}function all(values,convert){if(!Array.isArray(values))return null;const out=[];for(const value of values){const converted=convert(value);if(converted===null)return null;out.push(converted)}return out}export function createHqClient(options){const timeoutMs=options.timeoutMs??3000;const send=options.fetch??((input,init)=>fetch(input,init));const unavailable=(service,reason,message,status)=>({ok:false,unavailable:true,service,reason,message,...status===undefined?{}:{status}});async function read(service,path,parse){try{const config=options.services[service];if(!config?.url||!config.readKey)return unavailable(service,"not_configured",`${service} has no url or read key configured`);const target=path(config);if(target===null)return unavailable(service,"not_configured",`${service} has no project configured`);const controller=new AbortController;const timer=setTimeout(()=>controller.abort(),timeoutMs);let response;try{response=await send(`${config.url.replace(/\/+$/,"")}${target}`,{method:"GET",headers:{Authorization:`Bearer ${config.readKey}`,Accept:"application/json"},signal:controller.signal})}catch(error){return controller.signal.aborted?unavailable(service,"timeout",`${service} did not answer within ${timeoutMs}ms`):unavailable(service,"network",error instanceof Error?error.message:String(error))}finally{clearTimeout(timer)}if(response.status===401||response.status===403)return unavailable(service,"unauthorized",`${service} refused the read key`,response.status);if(!response.ok)return unavailable(service,"http",`${service} answered ${response.status}`,response.status);const data=parse(await response.json().catch(()=>{return}));return data===null?unavailable(service,"bad_response",`${service} answered a body that is not the documented shape`,response.status):{ok:true,data}}catch(error){return unavailable(service,"network",error instanceof Error?error.message:String(error))}}const project=(config,rest,query)=>{if(!config.project)return null;const search=query?.toString();return`/api/projects/${encodeURIComponent(config.project)}${rest}${search?`?${search}`:""}`};return{getUsage:(service)=>read(service,()=>"/api/v1/usage",(body)=>all(body,toUsageReport)),logs:{list:(query={})=>{const params=new URLSearchParams;const level=Array.isArray(query.level)?query.level.join(","):query.level;const fields=[["level",level],["channel",query.channel],["environment",query.environment],["trace",query.trace],["request",query.request],["q",query.search],["before",query.before],["limit",query.limit]];for(const[name,value]of fields){if(value!==undefined&&value!=="")params.set(name,String(value))}return read("loghq",(config)=>project(config,"/logs",params),(body)=>{const logs=isRow(body)?all(body.logs,toLogEntry):null;return logs===null?null:{logs,nextCursor:text(body.nextCursor)}})}},issues:{list:(query={})=>{const params=new URLSearchParams;if(query.status)params.set("status",query.status);return read("bughq",(config)=>project(config,"/issues",params),(body)=>isRow(body)?all(body.issues,toIssue):null)}}}}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { SqlClient, SqlDialect } from './types';
|
|
2
|
+
export declare function createMemoryConnectCodeStore(): ConnectCodeStore & { records: () => ConnectCodeRecord[] };
|
|
3
|
+
/** The package-owned schema for connect codes, for a migration or `ensureConnectCodesTable`. */
|
|
4
|
+
export declare function connectCodesMigrationSql(options?: ConnectCodesSchemaOptions): string[];
|
|
5
|
+
export declare function ensureConnectCodesTable(client: SqlClient, options?: ConnectCodesSchemaOptions): Promise<void>;
|
|
6
|
+
/**
|
|
7
|
+
* A `ConnectCodeStore` over `hq_connect_codes`.
|
|
8
|
+
*
|
|
9
|
+
* `approve` and `consume` each win or lose by writing a random claim in a
|
|
10
|
+
* guarded UPDATE and reading it back. That needs neither RETURNING nor an
|
|
11
|
+
* affected-row count, so one implementation is atomic on all three dialects.
|
|
12
|
+
*/
|
|
13
|
+
export declare function sqlConnectCodeStore(client: SqlClient, options?: ConnectCodesSchemaOptions): ConnectCodeStore;
|
|
14
|
+
export declare const CONNECT_CODES_TABLE: 'hq_connect_codes';
|
|
15
|
+
/**
|
|
16
|
+
* One pending or approved connect attempt.
|
|
17
|
+
*
|
|
18
|
+
* The code itself is never stored: `codeHash` is its SHA-256, and
|
|
19
|
+
* `sealedGrant` is the keys encrypted under a key derived from the code. A
|
|
20
|
+
* copy of this table therefore yields neither a code that can be polled nor
|
|
21
|
+
* the keys an approved one is waiting to hand over.
|
|
22
|
+
*/
|
|
23
|
+
export declare interface ConnectCodeRecord {
|
|
24
|
+
codeHash: string
|
|
25
|
+
app: string
|
|
26
|
+
status: 'pending' | 'approved'
|
|
27
|
+
sealedGrant: string | null
|
|
28
|
+
expiresAt: number
|
|
29
|
+
}
|
|
30
|
+
export declare interface ConnectCodeStore {
|
|
31
|
+
create: (record: ConnectCodeRecord) => Promise<void>
|
|
32
|
+
find: (codeHash: string) => Promise<ConnectCodeRecord | null>
|
|
33
|
+
approve: (codeHash: string, sealedGrant: string) => Promise<boolean>
|
|
34
|
+
consume: (codeHash: string) => Promise<ConnectCodeRecord | null>
|
|
35
|
+
prune: (now: number) => Promise<void>
|
|
36
|
+
}
|
|
37
|
+
export declare interface ConnectCodesSchemaOptions {
|
|
38
|
+
table?: string
|
|
39
|
+
dialect?: SqlDialect
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{placeholder,runSql,tableName}from"./sql";export function createMemoryConnectCodeStore(){const records=new Map;return{async create(record){records.set(record.codeHash,{...record})},async find(codeHash){const record=records.get(codeHash);return record?{...record}:null},async approve(codeHash,sealedGrant){const record=records.get(codeHash);if(!record||record.status!=="pending")return false;records.set(codeHash,{...record,status:"approved",sealedGrant});return true},async consume(codeHash){const record=records.get(codeHash);if(!record||record.status!=="approved")return null;records.delete(codeHash);return{...record}},async prune(now){for(const[hash,record]of records){if(record.expiresAt<now)records.delete(hash)}},records:()=>[...records.values()].map((record)=>({...record}))}}export const CONNECT_CODES_TABLE="hq_connect_codes";export function connectCodesMigrationSql(options={}){const table=tableName(options.table??CONNECT_CODES_TABLE);return[`CREATE TABLE IF NOT EXISTS ${table} (`+"code_hash CHAR(64) NOT NULL,"+"app VARCHAR(64) NOT NULL,"+"status VARCHAR(16) NOT NULL,"+"sealed_grant TEXT,"+"claim CHAR(36),"+"expires_at BIGINT NOT NULL,"+"PRIMARY KEY (code_hash)"+")"]}export async function ensureConnectCodesTable(client,options={}){for(const sql of connectCodesMigrationSql(options))await runSql(client,sql)}export function sqlConnectCodeStore(client,options={}){const table=tableName(options.table??CONNECT_CODES_TABLE);const dialect=options.dialect??"sqlite";const p=(n)=>placeholder(dialect,n);const columns="code_hash, app, status, sealed_grant, claim, expires_at";const toRecord=(row)=>({codeHash:String(row.code_hash),app:String(row.app),status:row.status==="approved"||row.status==="consumed"?"approved":"pending",sealedGrant:row.sealed_grant==null?null:String(row.sealed_grant),expiresAt:Number(row.expires_at)});async function row(codeHash){return(await runSql(client,`SELECT ${columns} FROM ${table} WHERE code_hash = ${p(1)} LIMIT 1`,[codeHash]))[0]??null}async function transition(codeHash,from,to,sealedGrant){const claim=crypto.randomUUID();if(sealedGrant===undefined){await runSql(client,`UPDATE ${table} SET status = ${p(1)}, claim = ${p(2)} WHERE code_hash = ${p(3)} AND status = ${p(4)}`,[to,claim,codeHash,from])}else{await runSql(client,`UPDATE ${table} SET status = ${p(1)}, claim = ${p(2)}, sealed_grant = ${p(3)} WHERE code_hash = ${p(4)} AND status = ${p(5)}`,[to,claim,sealedGrant,codeHash,from])}const after=await row(codeHash);return after&&after.status===to&&after.claim===claim?after:null}return{async create(record){await runSql(client,`INSERT INTO ${table} (code_hash, app, status, sealed_grant, expires_at) VALUES (${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, ${p(5)})`,[record.codeHash,record.app,record.status,record.sealedGrant,record.expiresAt])},async find(codeHash){const found=await row(codeHash);return found&&found.status!=="consumed"?toRecord(found):null},async approve(codeHash,sealedGrant){return await transition(codeHash,"pending","approved",sealedGrant)!==null},async consume(codeHash){const taken=await transition(codeHash,"approved","consumed");if(!taken)return null;await runSql(client,`DELETE FROM ${table} WHERE code_hash = ${p(1)}`,[codeHash]);return toRecord(taken)},async prune(now){await runSql(client,`DELETE FROM ${table} WHERE expires_at < ${p(1)}`,[now])}}}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { MeterPolicy, MeterPolicyName, UsageEvaluation } from './policy';
|
|
2
|
+
/** The meter that counts events an ingest policy dropped. */
|
|
3
|
+
export declare function droppedMeter(meter: string): string;
|
|
4
|
+
export declare function createCounter(options: CounterOptions): UsageCounter;
|
|
5
|
+
/** A `CounterStore` held in memory, for tests and for local development. */
|
|
6
|
+
export declare function createMemoryCounterStore(): CounterStore & { totals: () => CounterDelta[] };
|
|
7
|
+
/**
|
|
8
|
+
* Buffered usage counting for a hot path.
|
|
9
|
+
*
|
|
10
|
+
* The constraint that shapes this: it runs on ingest, the busiest endpoint a
|
|
11
|
+
* service has. Metering must add ZERO database round-trips there. So `record`
|
|
12
|
+
* and `admit` touch Maps and return, and a timer folds the accumulated deltas
|
|
13
|
+
* into the store every ten seconds.
|
|
14
|
+
*
|
|
15
|
+
* The fold is additive on the database's side (see `sqlCounterStore`), and
|
|
16
|
+
* its answer is the new durable total, which is how the in-memory view is
|
|
17
|
+
* refreshed. Nothing reads the table to decide an event.
|
|
18
|
+
*
|
|
19
|
+
* A lost flush (SIGKILL, a crash) costs at most one flush interval of counts,
|
|
20
|
+
* not the month, because the durable total already lives in the table.
|
|
21
|
+
*/
|
|
22
|
+
export declare interface CounterKey {
|
|
23
|
+
owner: string
|
|
24
|
+
meter: string
|
|
25
|
+
period: string
|
|
26
|
+
}
|
|
27
|
+
export declare interface CounterDelta extends CounterKey {
|
|
28
|
+
delta: number
|
|
29
|
+
}
|
|
30
|
+
export declare interface CounterStore {
|
|
31
|
+
add: (delta: CounterDelta) => Promise<number | null>
|
|
32
|
+
read: (key: CounterKey) => Promise<number>
|
|
33
|
+
}
|
|
34
|
+
export declare interface CounterOptions {
|
|
35
|
+
store: CounterStore
|
|
36
|
+
flushMs?: number
|
|
37
|
+
flushOnExit?: boolean
|
|
38
|
+
now?: () => Date
|
|
39
|
+
onError?: (error: unknown, key: CounterKey) => void
|
|
40
|
+
}
|
|
41
|
+
export declare interface Admission extends UsageEvaluation {
|
|
42
|
+
meter: string
|
|
43
|
+
accept: boolean
|
|
44
|
+
}
|
|
45
|
+
export declare interface UsageCounter {
|
|
46
|
+
record: (owner: string | number | null | undefined, meter: string, quantity?: number) => void
|
|
47
|
+
usageOf: (owner: string | number | null | undefined, meter: string) => number | null
|
|
48
|
+
admit: (owner: string | number | null | undefined, meter: string, limit: number | null, policy?: MeterPolicy | MeterPolicyName) => Admission
|
|
49
|
+
prime: (owner: string | number, meter: string) => Promise<void>
|
|
50
|
+
flush: () => Promise<void>
|
|
51
|
+
read: (owner: string | number, meter: string, period?: string) => Promise<number>
|
|
52
|
+
stop: () => void
|
|
53
|
+
}
|
package/dist/counter.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import process from"node:process";import{periodOf}from"./period";import{evaluate,graceCeiling}from"./policy";export function droppedMeter(meter){return`${meter}.dropped`}const SEPARATOR="\x00";function ownerId(owner){if(owner===null||owner===undefined)return null;if(typeof owner==="number")return Number.isFinite(owner)?String(owner):null;return owner.length>0&&!owner.includes(SEPARATOR)?owner:null}function keyOf(key){return`${key.period}${SEPARATOR}${key.owner}${SEPARATOR}${key.meter}`}function parseKey(id){const[period,owner,meter]=id.split(SEPARATOR);return{owner,meter,period}}export function createCounter(options){const{store}=options;const now=options.now??(()=>new Date);const onError=options.onError??((error,key)=>{console.error("[hq:meter] counter store failed for",`${key.owner}/${key.meter}`,error instanceof Error?error.message:error)});const flushMs=options.flushMs??1e4;const pending=new Map;const inFlight=new Map;const known=new Map;const priming=new Map;const bump=(map,id,by)=>{const next=(map.get(id)??0)+by;if(next===0)map.delete(id);else map.set(id,next)};const current=(owner,meter)=>({owner,meter,period:periodOf(now())});function local(id){if(!known.has(id)&&!pending.has(id)&&!inFlight.has(id))return null;return(known.get(id)??0)+(inFlight.get(id)??0)+(pending.get(id)??0)}function record(owner,meter,quantity=1){const id=ownerId(owner);if(id===null||!meter||!Number.isFinite(quantity)||quantity<=0)return;bump(pending,keyOf(current(id,meter)),quantity)}function usageOf(owner,meter){const id=ownerId(owner);return id===null?null:local(keyOf(current(id,meter)))}function prime(owner,meter){const id=ownerId(owner);if(id===null)return Promise.resolve();const key=current(id,meter);const keyId=keyOf(key);if(known.has(keyId))return Promise.resolve();let running=priming.get(keyId);if(!running){running=store.read(key).then((total)=>{if(!known.has(keyId))known.set(keyId,Number.isFinite(total)?total:0)}).catch((error)=>onError(error,key)).finally(()=>priming.delete(keyId));priming.set(keyId,running)}return running}function admit(owner,meter,limit,policy){const id=ownerId(owner);if(id===null)return{...evaluate(0,null,policy),meter,accept:true};const kept=local(keyOf(current(id,meter)));if(kept===null){prime(id,meter);prime(id,droppedMeter(meter))}const dropped=local(keyOf(current(id,droppedMeter(meter))))??0;const candidate=(kept??0)+1;const ceiling=graceCeiling(limit,policy);const sequence=ceiling===null?undefined:candidate+dropped-ceiling;const evaluation=evaluate(candidate,limit,policy,sequence);record(id,evaluation.allowed?meter:droppedMeter(meter));return{...evaluation,used:evaluation.allowed?candidate:candidate-1,meter,accept:evaluation.allowed}}async function flush(){if(pending.size===0)return;const batch=[...pending.entries()];pending.clear();for(const[id,delta]of batch)bump(inFlight,id,delta);const period=periodOf(now());await Promise.all(batch.map(async([id,delta])=>{const key=parseKey(id);try{const total=await store.add({...key,delta});if(typeof total==="number"&&Number.isFinite(total))known.set(id,total);else known.set(id,(known.get(id)??0)+delta)}catch(error){onError(error,key)}finally{bump(inFlight,id,-delta)}}));for(const id of known.keys()){if(!id.startsWith(`${period}${SEPARATOR}`))known.delete(id)}}async function read(owner,meter,period){const id=ownerId(owner);if(id===null)return 0;const key={owner:id,meter,period:period??periodOf(now())};const keyId=keyOf(key);const buffered=(inFlight.get(keyId)??0)+(pending.get(keyId)??0);try{return await store.read(key)+buffered}catch(error){onError(error,key);return(known.get(keyId)??0)+buffered}}let timer;const onSignal=()=>{flush()};const signals=["SIGTERM","SIGINT"];if(flushMs>0){timer=setInterval(()=>{flush()},flushMs);if(typeof timer==="object"&&timer&&"unref"in timer)timer.unref()}const exitHooks=options.flushOnExit??flushMs>0;if(exitHooks){for(const signal of signals){try{process.on(signal,onSignal)}catch{}}}function stop(){if(timer!==undefined)clearInterval(timer);timer=undefined;if(exitHooks){for(const signal of signals){try{process.off(signal,onSignal)}catch{}}}}return{record,usageOf,admit,prime,flush,read,stop}}export function createMemoryCounterStore(){const totals=new Map;return{async add(delta){const next=(totals.get(keyOf(delta))??0)+delta.delta;totals.set(keyOf(delta),next);return next},async read(key){return totals.get(keyOf(key))??0},totals:()=>[...totals.entries()].map(([id,delta])=>({...parseKey(id),delta}))}}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export*from"./client";export*from"./meter";export*from"./service";
|
package/dist/meter.d.ts
ADDED
package/dist/meter.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export*from"./counter";export*from"./period";export*from"./policy";export*from"./reservation";export*from"./usage-counters";export*from"./usage-header";
|
package/dist/period.d.ts
ADDED
package/dist/period.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function periodOf(at=new Date){return(typeof at==="string"?at:at.toISOString()).slice(0,7)}export function isPeriod(value){return typeof value==="string"&&/^\d{4}-(?:0[1-9]|1[0-2])$/.test(value)}
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { UsageState } from './types';
|
|
2
|
+
export declare function resolvePolicy(policy: MeterPolicy | MeterPolicyName | undefined): MeterPolicy;
|
|
3
|
+
/** The last count a policy takes in full: the limit plus its grace. */
|
|
4
|
+
export declare function graceCeiling(limit: number | null, policy?: MeterPolicy | MeterPolicyName): number | null;
|
|
5
|
+
/**
|
|
6
|
+
* Decide where `used` stands against `limit`. Pure: no clock, no randomness,
|
|
7
|
+
* no I/O, so the same inputs always give the same answer.
|
|
8
|
+
*
|
|
9
|
+
* `used` is the count INCLUDING the unit being decided, so `allowed` answers
|
|
10
|
+
* "is the unit that made it `used` taken?". To report standing usage with no
|
|
11
|
+
* unit in hand, pass the current count and read only `state`.
|
|
12
|
+
*
|
|
13
|
+
* Past the ceiling an ingest meter keeps every `keepEvery`th unit. Which one
|
|
14
|
+
* is decided by `sequence`, the number of units SEEN past the ceiling, kept
|
|
15
|
+
* or not. It defaults to `used - ceiling`, which is right when `used` counts
|
|
16
|
+
* everything received. A caller that counts only what it kept must pass the
|
|
17
|
+
* sequence itself, or the count stops advancing on the first dropped unit and
|
|
18
|
+
* every later one gets the same answer (`createCounter().admit` does this).
|
|
19
|
+
*/
|
|
20
|
+
export declare function evaluate(used: number, limit: number | null, policy?: MeterPolicy | MeterPolicyName, sequence?: number): UsageEvaluation;
|
|
21
|
+
/**
|
|
22
|
+
* `evaluate`, throwing `QuotaExceededError` when the unit is not taken. Meant
|
|
23
|
+
* for sends; an ingest route should branch on `allowed` and still answer 2xx.
|
|
24
|
+
*/
|
|
25
|
+
export declare function enforceQuota(meter: string, used: number, limit: number | null, options?: { policy?: MeterPolicy | MeterPolicyName, upgradeUrl?: string | null }): UsageEvaluation;
|
|
26
|
+
/**
|
|
27
|
+
* Ingest (errors, log lines, pageviews): never refuse. Take everything up to
|
|
28
|
+
* 120% of the allowance, then keep one event in ten. A refusal would be
|
|
29
|
+
* answered by an SDK retrying, which is more load for the same data.
|
|
30
|
+
*/
|
|
31
|
+
export declare const SOFT_INGEST: Readonly<MeterPolicy>;
|
|
32
|
+
/**
|
|
33
|
+
* Sends (email, SMS): the caller is waiting on the answer and each unit costs
|
|
34
|
+
* money, so after a 20% grace the send is refused with a typed error.
|
|
35
|
+
*/
|
|
36
|
+
export declare const HARD_SEND: Readonly<MeterPolicy>;
|
|
37
|
+
/**
|
|
38
|
+
* What a meter does as an account approaches and passes its allowance.
|
|
39
|
+
*
|
|
40
|
+
* Percentages are whole numbers and every threshold is computed in integers,
|
|
41
|
+
* so 120% of 500 is 600 on every machine rather than 599.9999 on some.
|
|
42
|
+
*/
|
|
43
|
+
export declare interface MeterPolicy {
|
|
44
|
+
kind: 'soft-ingest' | 'hard-send'
|
|
45
|
+
warnPercent: number
|
|
46
|
+
gracePercent: number
|
|
47
|
+
keepEvery: number
|
|
48
|
+
}
|
|
49
|
+
export declare interface UsageEvaluation {
|
|
50
|
+
state: UsageState
|
|
51
|
+
allowed: boolean
|
|
52
|
+
used: number
|
|
53
|
+
limit: number | null
|
|
54
|
+
ceiling: number | null
|
|
55
|
+
}
|
|
56
|
+
export type MeterPolicyName = MeterPolicy['kind'];
|
|
57
|
+
/** A send refused because the account is past its allowance and its grace. */
|
|
58
|
+
export declare class QuotaExceededError extends Error {
|
|
59
|
+
readonly code: 'quota_exceeded';
|
|
60
|
+
readonly meter: string;
|
|
61
|
+
readonly used: number;
|
|
62
|
+
readonly limit: number;
|
|
63
|
+
readonly upgradeUrl: string | null;
|
|
64
|
+
constructor(details: { meter: string, used: number, limit: number, upgradeUrl?: string | null });
|
|
65
|
+
toJSON(): { error: 'quota_exceeded', meter: string, used: number, limit: number, upgradeUrl: string | null };
|
|
66
|
+
}
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SOFT_INGEST=Object.freeze({kind:"soft-ingest",warnPercent:80,gracePercent:20,keepEvery:10});export const HARD_SEND=Object.freeze({kind:"hard-send",warnPercent:80,gracePercent:20,keepEvery:1});export function resolvePolicy(policy){if(policy===undefined||policy==="soft-ingest")return SOFT_INGEST;if(policy==="hard-send")return HARD_SEND;return policy}function count(value){return Number.isFinite(value)&&value>0?Math.floor(value):0}function unlimited(limit){return limit===null||limit===undefined||limit===Number.POSITIVE_INFINITY}export function graceCeiling(limit,policy){if(unlimited(limit))return null;const allowance=count(limit);const{gracePercent}=resolvePolicy(policy);return allowance+Math.floor(allowance*count(gracePercent)/100)}export function evaluate(used,limit,policy,sequence){const resolved=resolvePolicy(policy);const total=count(used);if(unlimited(limit))return{state:"ok",allowed:true,used:total,limit:null,ceiling:null};const allowance=count(limit);const ceiling=graceCeiling(allowance,resolved);const base={used:total,limit:allowance,ceiling};if(total>ceiling){if(resolved.kind==="hard-send")return{...base,state:"over",allowed:false};const keepEvery=Math.max(1,count(resolved.keepEvery));const seen=sequence===undefined?total-ceiling:count(sequence);return{...base,state:"sampled",allowed:seen>0&&seen%keepEvery===0}}if(total>=allowance)return{...base,state:"over",allowed:true};if(total*100>=allowance*count(resolved.warnPercent))return{...base,state:"warn",allowed:true};return{...base,state:"ok",allowed:true}}export class QuotaExceededError extends Error{code="quota_exceeded";meter;used;limit;upgradeUrl;constructor(details){super(`Usage limit exceeded for ${details.meter}: ${details.used} of ${details.limit}`);this.name="QuotaExceededError";this.meter=details.meter;this.used=details.used;this.limit=details.limit;this.upgradeUrl=details.upgradeUrl??null}toJSON(){return{error:this.code,meter:this.meter,used:this.used,limit:this.limit,upgradeUrl:this.upgradeUrl}}}export function enforceQuota(meter,used,limit,options={}){const evaluation=evaluate(used,limit,options.policy??HARD_SEND);if(!evaluation.allowed)throw new QuotaExceededError({meter,used:evaluation.used,limit:evaluation.limit??0,upgradeUrl:options.upgradeUrl});return evaluation}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare function reserveUsage(store: UsageQuotaStore, meter: string, quantity?: number, maxAttempts?: number): Promise<UsageReservation>;
|
|
2
|
+
export declare function releaseUsage(store: UsageQuotaStore, reservation: UsageReservation, maxAttempts?: number): Promise<void>;
|
|
3
|
+
export declare interface UsageQuotaSnapshot {
|
|
4
|
+
meter: string
|
|
5
|
+
used: number
|
|
6
|
+
limit: number | null
|
|
7
|
+
}
|
|
8
|
+
export declare interface UsageQuotaStore {
|
|
9
|
+
read: (meter: string) => Promise<UsageQuotaSnapshot | null>
|
|
10
|
+
compareAndSet: (snapshot: UsageQuotaSnapshot, nextUsed: number) => Promise<boolean>
|
|
11
|
+
}
|
|
12
|
+
export declare interface UsageReservation {
|
|
13
|
+
meter: string
|
|
14
|
+
quantity: number
|
|
15
|
+
previousUsed: number
|
|
16
|
+
used: number
|
|
17
|
+
limit: number | null
|
|
18
|
+
}
|
|
19
|
+
export type UsageQuotaErrorCode = 'unavailable' | 'invalid' | 'exceeded' | 'contention';
|
|
20
|
+
export declare class UsageQuotaError extends Error {
|
|
21
|
+
public readonly code: UsageQuotaErrorCode;
|
|
22
|
+
constructor(code: UsageQuotaErrorCode, meter: string);
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class UsageQuotaError extends Error{code;constructor(code,meter){const messages={unavailable:`Usage meter unavailable for ${meter}`,invalid:`Usage meter state is invalid for ${meter}`,exceeded:`Usage limit exceeded for ${meter}`,contention:`Usage reservation contention for ${meter}`};super(messages[code]);this.code=code;this.name="UsageQuotaError"}}function validQuantity(value){return Number.isFinite(value)&&value>0}function validSnapshot(snapshot,meter){return snapshot.meter===meter&&Number.isFinite(snapshot.used)&&snapshot.used>=0&&(snapshot.limit===null||Number.isFinite(snapshot.limit)&&snapshot.limit>=0)}function attempts(value){return Number.isInteger(value)&&value>0?value:1}export async function reserveUsage(store,meter,quantity=1,maxAttempts=5){if(!meter||!validQuantity(quantity))throw new UsageQuotaError("invalid",meter||"unknown");for(let attempt=0;attempt<attempts(maxAttempts);attempt++){const snapshot=await store.read(meter);if(!snapshot)throw new UsageQuotaError("unavailable",meter);if(!validSnapshot(snapshot,meter))throw new UsageQuotaError("invalid",meter);const nextUsed=snapshot.used+quantity;if(snapshot.limit!==null&&nextUsed>snapshot.limit)throw new UsageQuotaError("exceeded",meter);if(await store.compareAndSet(snapshot,nextUsed)){return{meter,quantity,previousUsed:snapshot.used,used:nextUsed,limit:snapshot.limit}}}throw new UsageQuotaError("contention",meter)}export async function releaseUsage(store,reservation,maxAttempts=5){if(!reservation.meter||!validQuantity(reservation.quantity))throw new UsageQuotaError("invalid",reservation.meter||"unknown");for(let attempt=0;attempt<attempts(maxAttempts);attempt++){const snapshot=await store.read(reservation.meter);if(!snapshot)throw new UsageQuotaError("unavailable",reservation.meter);if(!validSnapshot(snapshot,reservation.meter)||snapshot.used<reservation.quantity)throw new UsageQuotaError("invalid",reservation.meter);if(await store.compareAndSet(snapshot,snapshot.used-reservation.quantity))return}throw new UsageQuotaError("contention",reservation.meter)}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ConnectCodeStore } from './connect-codes';
|
|
2
|
+
import type { MeterPolicy, MeterPolicyName } from './policy';
|
|
3
|
+
import type { UsageReport } from './types';
|
|
4
|
+
export type { UsageReport } from './types';
|
|
5
|
+
/** SHA-256 of a connect code, hex. This, never the code, is what a store holds. */
|
|
6
|
+
export declare function hashConnectCode(code: string): Promise<string>;
|
|
7
|
+
export declare function createHqService<User = unknown>(adapter: HqServiceAdapter<User>, options?: HqServiceOptions): HqService<User>;
|
|
8
|
+
export declare const CONNECT_PATH: '/api/cli/connect';
|
|
9
|
+
export declare const USAGE_PATH: '/api/v1/usage';
|
|
10
|
+
/**
|
|
11
|
+
* The three endpoints every HQ service answers the same way:
|
|
12
|
+
*
|
|
13
|
+
* POST /api/cli/connect -> { code, approveUrl }
|
|
14
|
+
* GET /api/cli/connect/{code} -> pending | { project, ingestKey, readKey, dashboardUrl }
|
|
15
|
+
* GET /api/v1/usage (read key) -> { meter, used, limit, period, state, upgradeUrl }[]
|
|
16
|
+
*
|
|
17
|
+
* Nothing here knows a router or a database. A service implements
|
|
18
|
+
* `HqServiceAdapter` over its own users, projects and keys, and mounts the
|
|
19
|
+
* handlers in its own `routes/hq.ts`. Accounts stay the service's: no
|
|
20
|
+
* identity crosses from one service to another.
|
|
21
|
+
*/
|
|
22
|
+
export declare interface ConnectGrant {
|
|
23
|
+
project: { id: string, name: string }
|
|
24
|
+
ingestKey: string
|
|
25
|
+
readKey: string
|
|
26
|
+
dashboardUrl: string
|
|
27
|
+
}
|
|
28
|
+
export declare interface ConnectRequest {
|
|
29
|
+
app: string
|
|
30
|
+
}
|
|
31
|
+
/** Who a read key speaks for. */
|
|
32
|
+
export declare interface ReadPrincipal {
|
|
33
|
+
owner: string
|
|
34
|
+
project?: string
|
|
35
|
+
}
|
|
36
|
+
export declare interface MeterReading {
|
|
37
|
+
meter: string
|
|
38
|
+
used: number
|
|
39
|
+
limit: number | null
|
|
40
|
+
policy?: MeterPolicy | MeterPolicyName
|
|
41
|
+
period?: string
|
|
42
|
+
}
|
|
43
|
+
export declare interface HqServiceAdapter<User = unknown> {
|
|
44
|
+
service: string
|
|
45
|
+
approveUrl: (code: string) => string
|
|
46
|
+
upgradeUrl: string | ((principal: ReadPrincipal) => string)
|
|
47
|
+
codes: ConnectCodeStore
|
|
48
|
+
provision: (user: User, request: ConnectRequest) => Promise<ConnectGrant>
|
|
49
|
+
authenticateReadKey: (readKey: string) => Promise<ReadPrincipal | null>
|
|
50
|
+
usage: (principal: ReadPrincipal) => Promise<MeterReading[]>
|
|
51
|
+
}
|
|
52
|
+
export declare interface HqServiceOptions {
|
|
53
|
+
codeTtlMs?: number
|
|
54
|
+
now?: () => Date
|
|
55
|
+
}
|
|
56
|
+
/** The request shape the handlers read. A Fetch `Request` and bun-router's request both fit. */
|
|
57
|
+
export declare interface HqRequest {
|
|
58
|
+
url: string
|
|
59
|
+
method?: string
|
|
60
|
+
headers: { get: (name: string) => string | null }
|
|
61
|
+
json?: () => Promise<unknown>
|
|
62
|
+
params?: Record<string, string | undefined>
|
|
63
|
+
}
|
|
64
|
+
export declare interface HqRoute {
|
|
65
|
+
method: 'GET' | 'POST'
|
|
66
|
+
path: string
|
|
67
|
+
handler: (request: HqRequest) => Promise<Response>
|
|
68
|
+
}
|
|
69
|
+
export declare interface HqService<User = unknown> {
|
|
70
|
+
startConnect: (request: HqRequest) => Promise<Response>
|
|
71
|
+
pollConnect: (request: HqRequest, code?: string) => Promise<Response>
|
|
72
|
+
describeConnect: (code: string) => Promise<{ app: string, expiresAt: number } | null>
|
|
73
|
+
approveConnect: (code: string, user: User) => Promise<ApproveResult>
|
|
74
|
+
usage: (request: HqRequest) => Promise<Response>
|
|
75
|
+
routes: HqRoute[]
|
|
76
|
+
handle: (request: HqRequest) => Promise<Response | null>
|
|
77
|
+
}
|
|
78
|
+
export type ConnectPollResponse = | { status: 'pending' }
|
|
79
|
+
| { status: 'expired' }
|
|
80
|
+
| ({ status: 'approved' } & ConnectGrant);
|
|
81
|
+
export type ApproveResult = | { ok: true, project: ConnectGrant['project'] }
|
|
82
|
+
| { ok: false, reason: 'expired' | 'already_approved' }
|
|
83
|
+
export * from './connect-codes';
|
package/dist/service.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{isPeriod,periodOf}from"./period";import{evaluate}from"./policy";export*from"./connect-codes";export const CONNECT_PATH="/api/cli/connect";export const USAGE_PATH="/api/v1/usage";const encoder=new TextEncoder;const CODE=/^[\w-]{32,128}$/;function base64Url(bytes){let binary="";for(const byte of bytes)binary+=String.fromCharCode(byte);return btoa(binary).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function fromBase64Url(text){const binary=atob(text.replace(/-/g,"+").replace(/_/g,"/"));const bytes=new Uint8Array(new ArrayBuffer(binary.length));for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);return bytes}export async function hashConnectCode(code){const digest=new Uint8Array(await crypto.subtle.digest("SHA-256",encoder.encode(`hq-connect-code:${code}`)));return[...digest].map((byte)=>byte.toString(16).padStart(2,"0")).join("")}async function grantKey(code){const material=await crypto.subtle.digest("SHA-256",encoder.encode(`hq-connect-grant:${code}`));return crypto.subtle.importKey("raw",material,"AES-GCM",false,["encrypt","decrypt"])}async function sealGrant(code,grant){const iv=crypto.getRandomValues(new Uint8Array(new ArrayBuffer(12)));const sealed=new Uint8Array(await crypto.subtle.encrypt({name:"AES-GCM",iv},await grantKey(code),encoder.encode(JSON.stringify(grant))));return`${base64Url(iv)}.${base64Url(sealed)}`}async function openGrant(code,sealed){try{const[iv,body]=sealed.split(".");if(!iv||!body)return null;const plain=await crypto.subtle.decrypt({name:"AES-GCM",iv:fromBase64Url(iv)},await grantKey(code),fromBase64Url(body));return JSON.parse(new TextDecoder().decode(plain))}catch{return null}}function json(body,status=200){return new Response(JSON.stringify(body),{status,headers:{"Content-Type":"application/json","Cache-Control":"no-store"}})}function appName(value){const name=typeof value==="string"?value.replace(/[^\w .-]/g,"").trim().slice(0,64):"";return name||"app"}function pathOf(request){try{return new URL(request.url,"http://localhost").pathname.replace(/\/+$/,"")||"/"}catch{return""}}function bearer(request){const header=request.headers.get("authorization")??"";const match=/^Bearer\s+(\S+)$/i.exec(header.trim());return match?.[1]??null}export function createHqService(adapter,options={}){const now=options.now??(()=>new Date);const ttl=options.codeTtlMs??10*60*1000;async function live(code){if(!CODE.test(code))return null;const codeHash=await hashConnectCode(code);const record=await adapter.codes.find(codeHash);return record&&record.expiresAt>now().getTime()?record:null}async function startConnect(request){let body;try{body=await request.json?.()}catch{}const code=base64Url(crypto.getRandomValues(new Uint8Array(32)));const at=now().getTime();await adapter.codes.prune(at).catch(()=>{});await adapter.codes.create({codeHash:await hashConnectCode(code),app:appName(body?.app),status:"pending",sealedGrant:null,expiresAt:at+ttl});return json({code,approveUrl:adapter.approveUrl(code)},201)}async function describeConnect(code){const record=await live(code);return record&&record.status==="pending"?{app:record.app,expiresAt:record.expiresAt}:null}async function approveConnect(code,user){const record=await live(code);if(!record)return{ok:false,reason:"expired"};if(record.status!=="pending")return{ok:false,reason:"already_approved"};const grant=await adapter.provision(user,{app:record.app});const approved=await adapter.codes.approve(record.codeHash,await sealGrant(code,grant));return approved?{ok:true,project:grant.project}:{ok:false,reason:"already_approved"}}async function pollConnect(request,given){const code=given??request.params?.code??decodeURIComponent(pathOf(request).split("/").pop()??"");const expired=()=>json({status:"expired"},404);const record=await live(code);if(!record)return expired();if(record.status==="pending")return json({status:"pending"});const taken=await adapter.codes.consume(record.codeHash);const grant=taken?.sealedGrant?await openGrant(code,taken.sealedGrant):null;if(!grant)return expired();return json({status:"approved",...grant})}async function usage(request){const key=bearer(request);const principal=key?await adapter.authenticateReadKey(key):null;if(!principal)return json({error:"unauthorized"},401);const upgradeUrl=typeof adapter.upgradeUrl==="function"?adapter.upgradeUrl(principal):adapter.upgradeUrl;const period=periodOf(now());const reports=(await adapter.usage(principal)).map((reading)=>{const evaluation=evaluate(reading.used,reading.limit,reading.policy);return{meter:reading.meter,used:evaluation.used,limit:evaluation.limit,period:isPeriod(reading.period)?reading.period:period,state:evaluation.state,upgradeUrl}});return json(reports)}const routes=[{method:"POST",path:CONNECT_PATH,handler:startConnect},{method:"GET",path:`${CONNECT_PATH}/{code}`,handler:(request)=>pollConnect(request)},{method:"GET",path:USAGE_PATH,handler:usage}];async function handle(request){const method=(request.method??"GET").toUpperCase();const path=pathOf(request);if(method==="POST"&&path===CONNECT_PATH)return startConnect(request);if(method==="GET"&&path.startsWith(`${CONNECT_PATH}/`)&&!path.slice(CONNECT_PATH.length+1).includes("/"))return pollConnect(request,decodeURIComponent(path.slice(CONNECT_PATH.length+1)));if(method==="GET"&&path===USAGE_PATH)return usage(request);return null}return{startConnect,pollConnect,describeConnect,approveConnect,usage,routes,handle}}
|
package/dist/sql.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SqlClient, SqlDialect } from './types';
|
|
2
|
+
/** Table names are interpolated into DDL, so they are held to an identifier. */
|
|
3
|
+
export declare function tableName(table: string): string;
|
|
4
|
+
/** The nth bound parameter (1-based) in this dialect's spelling. */
|
|
5
|
+
export declare function placeholder(dialect: SqlDialect, n: number): string;
|
|
6
|
+
/**
|
|
7
|
+
* Run one statement and answer its rows.
|
|
8
|
+
*
|
|
9
|
+
* Clients disagree on what `unsafe` returns: a promise of rows, a lazy
|
|
10
|
+
* statement with `execute()`, or an object carrying `rows`. All three are
|
|
11
|
+
* accepted so an adapter never has to wrap its client to use these stores.
|
|
12
|
+
*/
|
|
13
|
+
export declare function runSql(client: SqlClient, sql: string, params?: unknown[]): Promise<Record<string, unknown>[]>;
|
|
14
|
+
export declare class HqSchemaError extends Error {
|
|
15
|
+
constructor(message: string);
|
|
16
|
+
}
|
package/dist/sql.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class HqSchemaError extends Error{constructor(message){super(message);this.name="HqSchemaError"}}export function tableName(table){if(!/^[A-Z_]\w*$/i.test(table))throw new HqSchemaError(`Invalid table name '${table}'.`);return table}export function placeholder(dialect,n){return dialect==="postgres"?`$${n}`:"?"}export async function runSql(client,sql,params=[]){const statement=client.unsafe(sql,params);const result=statement&&typeof statement.execute==="function"?await statement.execute():await statement;if(Array.isArray(result))return result;const rows=result?.rows;return Array.isArray(rows)?rows:[]}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ConnectGrant, HqServiceAdapter, MeterReading } from './service';
|
|
2
|
+
export declare function hqServiceContract<User>(name: string, setup: () => HqContractHarness<User> | Promise<HqContractHarness<User>>): void;
|
|
3
|
+
/**
|
|
4
|
+
* A complete adapter held in memory: a stand-in service for an app's own
|
|
5
|
+
* tests, and the reference the contract suite is checked against.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createMemoryHqAdapter(options?: MemoryHqAdapterOptions): HqServiceAdapter<MemoryHqUser> & { grants: () => ConnectGrant[] };
|
|
8
|
+
/**
|
|
9
|
+
* The contract every HQ service's adapter must pass, and an in-memory adapter
|
|
10
|
+
* that passes it.
|
|
11
|
+
*
|
|
12
|
+
* A service runs the suite against its real adapter:
|
|
13
|
+
*
|
|
14
|
+
* import { hqServiceContract } from '@stacksjs/hq/testing'
|
|
15
|
+
* hqServiceContract('bughq', async () => ({ adapter: bughqAdapter, user: await aSignedInUser() }))
|
|
16
|
+
*
|
|
17
|
+
* This module imports `bun:test`, so it is only for test files.
|
|
18
|
+
*/
|
|
19
|
+
export declare interface HqContractHarness<User> {
|
|
20
|
+
adapter: HqServiceAdapter<User>
|
|
21
|
+
user: User
|
|
22
|
+
}
|
|
23
|
+
export declare interface MemoryHqAdapterOptions {
|
|
24
|
+
service?: string
|
|
25
|
+
origin?: string
|
|
26
|
+
meters?: MeterReading[]
|
|
27
|
+
}
|
|
28
|
+
export declare interface MemoryHqUser {
|
|
29
|
+
id: string
|
|
30
|
+
}
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{describe,expect,test}from"bun:test";import{isPeriod}from"./period";import{createHqService,createMemoryConnectCodeStore,hashConnectCode}from"./service";const STATES=["ok","warn","over","sampled"];function request(url,init={}){return new Request(`http://hq.test${url}`,{method:init.method??"GET",headers:{...init.body===undefined?{}:{"Content-Type":"application/json"},...init.headers},body:init.body===undefined?undefined:JSON.stringify(init.body)})}export function hqServiceContract(name,setup){describe(`HQ service contract: ${name}`,()=>{async function boot(){const harness=await setup();const clock={at:Date.parse("2026-01-15T12:00:00.000Z")};const service=createHqService(harness.adapter,{now:()=>new Date(clock.at),codeTtlMs:60000});const start=async(app="contract-app")=>{const response=await service.handle(request("/api/cli/connect",{method:"POST",body:{app}}));expect(response?.status).toBe(201);return await response.json()};const poll=async(code)=>{const response=await service.handle(request(`/api/cli/connect/${code}`));return{status:response.status,body:await response.json()}};return{...harness,service,clock,start,poll}}test("starting a connect answers a code and an approve URL that carries it",async()=>{const{start,service}=await boot();const{code,approveUrl}=await start("my-app");expect(code).toMatch(/^[\w-]{32,}$/);expect(approveUrl).toContain(code);expect(()=>new URL(approveUrl)).not.toThrow();expect(await service.describeConnect(code)).toMatchObject({app:"my-app"})});test("two connects never share a code",async()=>{const{start}=await boot();expect((await start()).code).not.toBe((await start()).code)});test("the store is given the hash of the code, never the code",async()=>{const{start,adapter}=await boot();const{code}=await start();expect(await adapter.codes.find(code)).toBeNull();const record=await adapter.codes.find(await hashConnectCode(code));expect(record).toMatchObject({status:"pending",sealedGrant:null});expect(JSON.stringify(record)).not.toContain(code)});test("a code is pending until a signed-in user approves it",async()=>{const{start,poll}=await boot();const{code}=await start();expect(await poll(code)).toEqual({status:200,body:{status:"pending"}});expect(await poll(code)).toEqual({status:200,body:{status:"pending"}})});test("an approved code hands over the project and its keys, once",async()=>{const{start,poll,service,user,adapter}=await boot();const{code}=await start("my-app");const approval=await service.approveConnect(code,user);expect(approval.ok).toBe(true);const stored=await adapter.codes.find(await hashConnectCode(code));expect(stored?.status).toBe("approved");const first=await poll(code);expect(first.status).toBe(200);expect(first.body.status).toBe("approved");const grant=first.body;expect(typeof grant.project.id).toBe("string");expect(typeof grant.project.name).toBe("string");expect(grant.ingestKey.length).toBeGreaterThan(0);expect(grant.readKey.length).toBeGreaterThan(0);expect(grant.readKey).not.toBe(grant.ingestKey);expect(()=>new URL(grant.dashboardUrl)).not.toThrow();expect(stored?.sealedGrant??"").not.toContain(grant.readKey);expect(await poll(code)).toEqual({status:404,body:{status:"expired"}});expect(await service.approveConnect(code,user)).toEqual({ok:false,reason:"expired"})});test("concurrent polls of an approved code yield the keys exactly once",async()=>{const{start,poll,service,user}=await boot();const{code}=await start();await service.approveConnect(code,user);const outcomes=await Promise.all([poll(code),poll(code),poll(code)]);expect(outcomes.filter((outcome)=>outcome.body.status==="approved")).toHaveLength(1);expect(outcomes.filter((outcome)=>outcome.status===404)).toHaveLength(2)});test("a code cannot be approved twice",async()=>{const{start,service,user}=await boot();const{code}=await start();expect((await service.approveConnect(code,user)).ok).toBe(true);expect(await service.approveConnect(code,user)).toEqual({ok:false,reason:"already_approved"})});test("a code expires, approved or not",async()=>{const{start,poll,service,user,clock}=await boot();const pending=await start();const approved=await start();await service.approveConnect(approved.code,user);clock.at+=60001;expect(await poll(pending.code)).toEqual({status:404,body:{status:"expired"}});expect(await poll(approved.code)).toEqual({status:404,body:{status:"expired"}});expect(await service.approveConnect(pending.code,user)).toEqual({ok:false,reason:"expired"});expect(await service.describeConnect(pending.code)).toBeNull()});test("an unknown or malformed code is expired, not an error",async()=>{const{poll}=await boot();expect((await poll("a".repeat(43))).status).toBe(404);expect((await poll("nope")).status).toBe(404)});test("connecting the same app twice reuses its project",async()=>{const{start,poll,service,user}=await boot();const grants=[];for(let i=0;i<2;i++){const{code}=await start("same-app");await service.approveConnect(code,user);grants.push((await poll(code)).body)}expect(grants[1].project.id).toBe(grants[0].project.id)});test("usage refuses a missing, malformed or wrong read key",async()=>{const{service,start,poll,user}=await boot();const{code}=await start();await service.approveConnect(code,user);const grant=(await poll(code)).body;const attempts=[undefined,{Authorization:"Bearer"},{Authorization:`Basic ${grant.readKey}`},{Authorization:"Bearer not-a-key"},{Authorization:`Bearer ${grant.ingestKey}`}];for(const headers of attempts){const response=await service.handle(request("/api/v1/usage",{headers}));expect(response?.status).toBe(401)}});test("usage answers every meter in the documented shape",async()=>{const{service,start,poll,user}=await boot();const{code}=await start();await service.approveConnect(code,user);const grant=(await poll(code)).body;const response=await service.handle(request("/api/v1/usage",{headers:{Authorization:`Bearer ${grant.readKey}`}}));expect(response?.status).toBe(200);const reports=await response.json();expect(Array.isArray(reports)).toBe(true);expect(reports.length).toBeGreaterThan(0);for(const report of reports){expect(Object.keys(report).sort()).toEqual(["limit","meter","period","state","upgradeUrl","used"]);expect(report.meter).toMatch(/^[\w.:-]{1,64}$/);expect(Number.isInteger(report.used)&&report.used>=0).toBe(true);expect(report.limit===null||Number.isInteger(report.limit)&&report.limit>=0).toBe(true);expect(isPeriod(report.period)).toBe(true);expect(STATES).toContain(report.state);expect(()=>new URL(report.upgradeUrl)).not.toThrow()}expect(new Set(reports.map((report)=>report.meter)).size).toBe(reports.length)});test("requests outside the contract are left to the service",async()=>{const{service}=await boot();expect(await service.handle(request("/api/v1/other"))).toBeNull();expect(await service.handle(request("/api/cli/connect"))).toBeNull();expect(await service.handle(request("/api/v1/usage",{method:"POST",body:{}}))).toBeNull()})})}export function createMemoryHqAdapter(options={}){const service=options.service??"fakehq";const origin=options.origin??`https://${service}.test`;const meters=options.meters??[{meter:"events",used:0,limit:500}];const projects=new Map;return{service,approveUrl:(code)=>`${origin}/cli/connect?code=${encodeURIComponent(code)}`,upgradeUrl:`${origin}/pricing`,codes:createMemoryConnectCodeStore(),async provision(user,{app}){const key=`${user.id}/${app}`;let project=projects.get(key);if(!project){const id=crypto.randomUUID();project={owner:user.id,grant:{project:{id,name:app},ingestKey:`${service}_ingest_${crypto.randomUUID()}`,readKey:`${service}_read_${crypto.randomUUID()}`,dashboardUrl:`${origin}/projects/${id}`}};projects.set(key,project)}return project.grant},async authenticateReadKey(readKey){for(const{grant,owner}of projects.values()){if(grant.readKey===readKey)return{owner,project:grant.project.id}}return null},async usage(){return meters.map((reading)=>({...reading}))},grants:()=>[...projects.values()].map((project)=>project.grant)}}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** One row of `GET /api/v1/usage`, and what a reader hands an app. */
|
|
2
|
+
export declare interface UsageReport {
|
|
3
|
+
meter: string
|
|
4
|
+
used: number
|
|
5
|
+
limit: number | null
|
|
6
|
+
period: string
|
|
7
|
+
state: UsageState
|
|
8
|
+
upgradeUrl: string
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The slice of a database client this package needs. `db` from
|
|
12
|
+
* `@stacksjs/database` satisfies it; so does anything else that can run a
|
|
13
|
+
* parameterised statement, which is what keeps this package free of a
|
|
14
|
+
* database dependency.
|
|
15
|
+
*/
|
|
16
|
+
export declare interface SqlClient {
|
|
17
|
+
unsafe: (sql: string, params?: unknown[]) => unknown
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Where an account stands against one meter.
|
|
21
|
+
*
|
|
22
|
+
* - `ok`: under 80% of the allowance
|
|
23
|
+
* - `warn`: at or past 80%, still inside it
|
|
24
|
+
* - `over`: the allowance is used up; the grace is being spent (or, for a
|
|
25
|
+
* send, has run out)
|
|
26
|
+
* - `sampled`: an ingest meter past its grace, keeping one event in ten
|
|
27
|
+
*/
|
|
28
|
+
export type UsageState = 'ok' | 'warn' | 'over' | 'sampled';
|
|
29
|
+
export type SqlDialect = 'sqlite' | 'mysql' | 'postgres';
|
package/dist/types.js
ADDED
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CounterStore } from './counter';
|
|
2
|
+
import type { SqlClient, SqlDialect } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* The package-owned schema for `usage_counters`: one row per
|
|
5
|
+
* (owner, meter, period), `used` as a whole number of units.
|
|
6
|
+
*
|
|
7
|
+
* Returned for a service to put in a migration, or to hand to
|
|
8
|
+
* `ensureUsageCountersTable`. No migration file is written implicitly.
|
|
9
|
+
*/
|
|
10
|
+
export declare function usageCountersMigrationSql(options?: UsageCountersSchemaOptions): string[];
|
|
11
|
+
/** Explicitly provision the table using the package-owned schema. */
|
|
12
|
+
export declare function ensureUsageCountersTable(client: SqlClient, options?: UsageCountersSchemaOptions): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* A `CounterStore` over `usage_counters`.
|
|
15
|
+
*
|
|
16
|
+
* `add` is an upsert whose update is `used = used + delta`, computed by the
|
|
17
|
+
* database. Any number of app instances therefore converge on one total
|
|
18
|
+
* instead of overwriting each other, and the statement's answer is the new
|
|
19
|
+
* total, so the counter never issues a separate read to learn where an
|
|
20
|
+
* account stands.
|
|
21
|
+
*/
|
|
22
|
+
export declare function sqlCounterStore(client: SqlClient, options?: UsageCountersSchemaOptions): CounterStore;
|
|
23
|
+
export declare const USAGE_COUNTERS_TABLE: 'usage_counters';
|
|
24
|
+
export declare interface UsageCountersSchemaOptions {
|
|
25
|
+
table?: string
|
|
26
|
+
dialect?: SqlDialect
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{placeholder,runSql,tableName}from"./sql";export const USAGE_COUNTERS_TABLE="usage_counters";export function usageCountersMigrationSql(options={}){const table=tableName(options.table??USAGE_COUNTERS_TABLE);const dialect=options.dialect??"sqlite";const timestamp=dialect==="postgres"?"TIMESTAMPTZ":dialect==="mysql"?"TIMESTAMP":"DATETIME";return[`CREATE TABLE IF NOT EXISTS ${table} (`+"owner VARCHAR(191) NOT NULL,"+"meter VARCHAR(64) NOT NULL,"+"period CHAR(7) NOT NULL,"+"used BIGINT NOT NULL DEFAULT 0,"+`updated_at ${timestamp} DEFAULT CURRENT_TIMESTAMP,`+"PRIMARY KEY (owner, meter, period)"+")"]}export async function ensureUsageCountersTable(client,options={}){for(const sql of usageCountersMigrationSql(options))await runSql(client,sql)}export function sqlCounterStore(client,options={}){const table=tableName(options.table??USAGE_COUNTERS_TABLE);const dialect=options.dialect??"sqlite";const p=(n)=>placeholder(dialect,n);const select=`SELECT used FROM ${table} WHERE owner = ${p(1)} AND meter = ${p(2)} AND period = ${p(3)} LIMIT 1`;async function read(key){const row=(await runSql(client,select,[key.owner,key.meter,key.period]))[0];return Number(row?.used??0)}return{read,async add({owner,meter,period,delta}){const values=[owner,meter,period,delta];if(dialect==="mysql"){await runSql(client,`INSERT INTO ${table} (owner, meter, period, used, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) `+"ON DUPLICATE KEY UPDATE used = used + VALUES(used), updated_at = CURRENT_TIMESTAMP",values);return read({owner,meter,period})}const row=(await runSql(client,`INSERT INTO ${table} (owner, meter, period, used, updated_at) VALUES (${p(1)}, ${p(2)}, ${p(3)}, ${p(4)}, CURRENT_TIMESTAMP) `+`ON CONFLICT (owner, meter, period) DO UPDATE SET used = ${table}.used + EXCLUDED.used, updated_at = CURRENT_TIMESTAMP `+"RETURNING used",values))[0];return row?Number(row.used??0):null}}}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { UsageState } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* `meter;used;limit;state;upgrade=<url>`
|
|
4
|
+
*
|
|
5
|
+
* The URL is percent-encoded so a `;` or `,` inside it cannot be read as a
|
|
6
|
+
* field or a second header value.
|
|
7
|
+
*/
|
|
8
|
+
export declare function formatUsageHeader(usage: UsageHeader): string;
|
|
9
|
+
/**
|
|
10
|
+
* Read an `X-HQ-Usage` value. Answers `null` for anything malformed: the
|
|
11
|
+
* header is advice, and a reader must never fail a request over it.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseUsageHeader(value: string | null | undefined): UsageHeader | null;
|
|
14
|
+
/** The response header an ingest endpoint answers with on every 2xx. */
|
|
15
|
+
export declare const USAGE_HEADER: 'X-HQ-Usage';
|
|
16
|
+
export declare interface UsageHeader {
|
|
17
|
+
meter: string
|
|
18
|
+
used: number
|
|
19
|
+
limit: number | null
|
|
20
|
+
state: UsageState
|
|
21
|
+
upgradeUrl: string | null
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const USAGE_HEADER="X-HQ-Usage";const STATES=new Set(["ok","warn","over","sampled"]);const METER=/^[\w.:-]{1,64}$/;function wholeNumber(value){return/^\d{1,15}$/.test(value)?Number(value):null}export function formatUsageHeader(usage){if(!METER.test(usage.meter))throw new TypeError(`Invalid meter name '${usage.meter}'.`);const used=Math.max(0,Math.floor(Number.isFinite(usage.used)?usage.used:0));const limit=usage.limit===null?"unlimited":String(Math.max(0,Math.floor(usage.limit)));const fields=[usage.meter,String(used),limit,usage.state];if(usage.upgradeUrl)fields.push(`upgrade=${encodeURIComponent(usage.upgradeUrl)}`);return fields.join(";")}export function parseUsageHeader(value){if(typeof value!=="string"||value.length===0||value.length>2048)return null;const[meter,usedText,limitText,state,...parameters]=value.trim().split(";").map((part)=>part.trim());if(!meter||!METER.test(meter)||usedText===undefined||limitText===undefined||!state||!STATES.has(state))return null;const used=wholeNumber(usedText);const limit=limitText==="unlimited"?null:wholeNumber(limitText);if(used===null||limit===null&&limitText!=="unlimited")return null;let upgradeUrl=null;for(const parameter of parameters){if(!parameter.startsWith("upgrade="))continue;try{const url=decodeURIComponent(parameter.slice("upgrade=".length));if(/^https?:\/\//i.test(url))upgradeUrl=url}catch{return null}}return{meter,used,limit,state,upgradeUrl}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stacksjs/hq",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.75.542",
|
|
5
|
+
"description": "What every Stacks HQ service does the same way: usage metering, the connect and usage endpoints, and typed readers.",
|
|
6
|
+
"author": "Chris Breuer",
|
|
7
|
+
"contributors": [
|
|
8
|
+
"Chris Breuer <chris@stacksjs.com>"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"funding": "https://github.com/sponsors/chrisbbreuer",
|
|
12
|
+
"homepage": "https://github.com/stacksjs/stacks/tree/main/storage/framework/core/hq#readme",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
16
|
+
"directory": "./storage/framework/core/hq"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/stacksjs/stacks/issues"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"metering",
|
|
23
|
+
"usage",
|
|
24
|
+
"quota",
|
|
25
|
+
"saas",
|
|
26
|
+
"stacks"
|
|
27
|
+
],
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"bun": "./dist/index.js",
|
|
32
|
+
"import": "./dist/index.js",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./*": {
|
|
36
|
+
"types": "./dist/*.d.ts",
|
|
37
|
+
"bun": "./dist/*.js",
|
|
38
|
+
"import": "./dist/*.js",
|
|
39
|
+
"default": "./dist/*.js"
|
|
40
|
+
},
|
|
41
|
+
"./*.js": {
|
|
42
|
+
"types": "./dist/*.d.ts",
|
|
43
|
+
"bun": "./dist/*.js",
|
|
44
|
+
"import": "./dist/*.js",
|
|
45
|
+
"default": "./dist/*.js"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"module": "dist/index.js",
|
|
49
|
+
"types": "dist/index.d.ts",
|
|
50
|
+
"files": [
|
|
51
|
+
"README.md",
|
|
52
|
+
"dist"
|
|
53
|
+
],
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "bun build.ts",
|
|
56
|
+
"typecheck": "bun tsc --noEmit",
|
|
57
|
+
"prepublishOnly": "bun run build"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"better-dx": "^0.2.24"
|
|
61
|
+
},
|
|
62
|
+
"sideEffects": false
|
|
63
|
+
}
|