@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 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
- # Temporary Holding Version
1
+ # @stacksjs/hq
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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`.
@@ -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
+ }
@@ -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}))}}
@@ -0,0 +1,3 @@
1
+ export * from './client';
2
+ export * from './meter';
3
+ export * from './service';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export*from"./client";export*from"./meter";export*from"./service";
@@ -0,0 +1,7 @@
1
+ export type { SqlClient, SqlDialect, UsageReport, UsageState } from './types';
2
+ export * from './counter';
3
+ export * from './period';
4
+ export * from './policy';
5
+ export * from './reservation';
6
+ export * from './usage-counters';
7
+ export * from './usage-header';
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";
@@ -0,0 +1,3 @@
1
+ /** The metering period a moment falls in: `YYYY-MM`, in UTC. */
2
+ export declare function periodOf(at?: Date | string): string;
3
+ export declare function isPeriod(value: unknown): value is string;
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)}
@@ -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';
@@ -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
+ }
@@ -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)}}
@@ -0,0 +1,5 @@
1
+ {
2
+ "compilerOptions": {
3
+ "paths": {}
4
+ }
5
+ }
@@ -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
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }