@stacksjs/hq 0.75.561 → 0.75.568

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/README.md CHANGED
@@ -109,6 +109,13 @@ for (const { method, path, handler } of hq.routes)
109
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
110
  | `GET /api/v1/usage` | `200 { meter, used, limit, period, state, upgradeUrl }[]`; `401` without a valid `Authorization: Bearer <read key>` |
111
111
 
112
+ A service that takes no data from the app, because its product runs inside the
113
+ app, has no keys to hand over. It answers a `LicenseGrant`
114
+ (`{ project, licenseKey, dashboardUrl }`) from `provision` and leaves
115
+ `authenticateReadKey` and `usage` off its adapter. An approved poll then
116
+ carries `licenseKey` in place of the two keys, `routes` holds the two connect
117
+ routes only, and the usage handler answers `404`.
118
+
112
119
  The approve page is the service's own. It calls `hq.describeConnect(code)` to
113
120
  show which app is asking and `hq.approveConnect(code, user)` when the signed-in
114
121
  user confirms.
package/dist/client.d.ts CHANGED
@@ -120,7 +120,7 @@ export declare interface HqClient {
120
120
  * down or misconfigured must cost that page a panel, not the page. Every
121
121
  * failure is a typed `HqUnavailable` value.
122
122
  */
123
- export type HqServiceName = 'bughq' | 'loghq' | 'analyticshq' | 'statushq' | 'commshq';
123
+ export type HqServiceName = 'bughq' | 'loghq' | 'analyticshq' | 'statushq' | 'commshq' | 'reportshq';
124
124
  export type HqUnavailableReason = | 'not_configured'
125
125
  /** The read key was refused. */
126
126
  | 'unauthorized'
package/dist/service.d.ts CHANGED
@@ -2,6 +2,8 @@ import type { ConnectCodeStore } from './connect-codes';
2
2
  import type { MeterPolicy, MeterPolicyName } from './policy';
3
3
  import type { UsageReport } from './types';
4
4
  export type { UsageReport } from './types';
5
+ /** Whether an approved connect carried a licence rather than keys. */
6
+ export declare function isLicenseGrant(grant: ConnectGrant | LicenseGrant): grant is LicenseGrant;
5
7
  /** SHA-256 of a connect code, hex. This, never the code, is what a store holds. */
6
8
  export declare function hashConnectCode(code: string): Promise<string>;
7
9
  export declare function createHqService<User = unknown>(adapter: HqServiceAdapter<User>, options?: HqServiceOptions): HqService<User>;
@@ -12,6 +14,7 @@ export declare const USAGE_PATH: '/api/v1/usage';
12
14
  *
13
15
  * POST /api/cli/connect -> { code, approveUrl }
14
16
  * GET /api/cli/connect/{code} -> pending | { project, ingestKey, readKey, dashboardUrl }
17
+ * (or { project, licenseKey, dashboardUrl } from a licence-only service)
15
18
  * GET /api/v1/usage (read key) -> { meter, used, limit, period, state, upgradeUrl }[]
16
19
  *
17
20
  * Nothing here knows a router or a database. A service implements
@@ -25,6 +28,19 @@ export declare interface ConnectGrant {
25
28
  readKey: string
26
29
  dashboardUrl: string
27
30
  }
31
+ /**
32
+ * What a service with no keys hands over instead.
33
+ *
34
+ * Some services take no data from the app at all: the product runs inside the
35
+ * app, and the one thing an account gives it is a licence key. Such a service
36
+ * answers this from `provision`, leaves `authenticateReadKey` and `usage` off
37
+ * its adapter, and has no usage route.
38
+ */
39
+ export declare interface LicenseGrant {
40
+ project: { id: string, name: string }
41
+ licenseKey: string
42
+ dashboardUrl: string
43
+ }
28
44
  export declare interface ConnectRequest {
29
45
  app: string
30
46
  }
@@ -45,9 +61,9 @@ export declare interface HqServiceAdapter<User = unknown> {
45
61
  approveUrl: (code: string) => string
46
62
  upgradeUrl: string | ((principal: ReadPrincipal) => string)
47
63
  codes: ConnectCodeStore
48
- provision: (user: User, request: ConnectRequest) => Promise<ConnectGrant>
49
- authenticateReadKey: (readKey: string) => Promise<ReadPrincipal | null>
50
- usage: (principal: ReadPrincipal) => Promise<MeterReading[]>
64
+ provision: (user: User, request: ConnectRequest) => Promise<ConnectGrant | LicenseGrant>
65
+ authenticateReadKey?: (readKey: string) => Promise<ReadPrincipal | null>
66
+ usage?: (principal: ReadPrincipal) => Promise<MeterReading[]>
51
67
  }
52
68
  export declare interface HqServiceOptions {
53
69
  codeTtlMs?: number
@@ -77,7 +93,8 @@ export declare interface HqService<User = unknown> {
77
93
  }
78
94
  export type ConnectPollResponse = | { status: 'pending' }
79
95
  | { status: 'expired' }
80
- | ({ status: 'approved' } & ConnectGrant);
96
+ | ({ status: 'approved' } & ConnectGrant)
97
+ | ({ status: 'approved' } & LicenseGrant);
81
98
  export type ApproveResult = | { ok: true, project: ConnectGrant['project'] }
82
99
  | { ok: false, reason: 'expired' | 'already_approved' }
83
100
  export * from './connect-codes';
package/dist/service.js CHANGED
@@ -1 +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}}
1
+ import{isPeriod,periodOf}from"./period";import{evaluate}from"./policy";export*from"./connect-codes";export function isLicenseGrant(grant){return typeof grant.licenseKey==="string"}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})}const metered=Boolean(adapter.authenticateReadKey&&adapter.usage);async function usage(request){if(!adapter.authenticateReadKey||!adapter.usage)return json({error:"not found"},404);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)},...metered?[{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(metered&&method==="GET"&&path===USAGE_PATH)return usage(request);return null}return{startConnect,pollConnect,describeConnect,approveConnect,usage,routes,handle}}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/hq",
3
3
  "type": "module",
4
- "version": "0.75.561",
4
+ "version": "0.75.568",
5
5
  "description": "What every Stacks HQ service does the same way: usage metering, the connect and usage endpoints, and typed readers.",
6
6
  "author": "Chris Breuer",
7
7
  "contributors": [