@stacksjs/hq 0.75.568 → 0.75.571

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
@@ -120,6 +120,14 @@ The approve page is the service's own. It calls `hq.describeConnect(code)` to
120
120
  show which app is asking and `hq.approveConnect(code, user)` when the signed-in
121
121
  user confirms.
122
122
 
123
+ An approve link can be minted by anyone for any app name, so approving on the
124
+ link alone would let a signed-in person be talked into handing over their keys.
125
+ `confirmationCode(code)` is a six-character code derived from the connect code.
126
+ Send it as `confirmCode` beside `code` and `approveUrl` (`buddy hq:connect`
127
+ prints it), ask for it on the approve page without rendering it, and call
128
+ `approveConnect` only once `confirmationMatches(code, typed)` holds. Limit the
129
+ attempts per person.
130
+
123
131
  Codes are single-use and live ten minutes (`codeTtlMs`). A store only ever sees
124
132
  the SHA-256 of a code, and approved keys wait encrypted under a key derived from
125
133
  the code itself, so a copy of the table yields neither a code that can be polled
@@ -141,6 +149,9 @@ concurrent polls, expiry, project reuse, read-key refusal (including the ingest
141
149
  key) and the usage shape. `createMemoryHqAdapter()` passes it and doubles as a
142
150
  stand-in service for an app's tests.
143
151
 
152
+ A licence-only adapter runs the same suite. There it checks that an approved
153
+ poll carries a licence and no keys, and that the service has no usage route.
154
+
144
155
  ## Client
145
156
 
146
157
  ```ts
@@ -0,0 +1,8 @@
1
+ /** The confirmation code of a connect code, as `ABC-DEF`. */
2
+ export declare function confirmationCode(connectCode: string): Promise<string>;
3
+ /**
4
+ * Whether what a person typed is the confirmation code of this connect code.
5
+ * Compared in constant time. Limit the attempts per person in the router: six
6
+ * characters are only as strong as the number of guesses allowed.
7
+ */
8
+ export declare function confirmationMatches(connectCode: string, typed: unknown): Promise<boolean>;
@@ -0,0 +1 @@
1
+ const ALPHABET="ABCDEFGHJKMNPQRSTUVWXYZ23456789";const LENGTH=6;const encoder=new TextEncoder;export async function confirmationCode(connectCode){const digest=new Uint8Array(await crypto.subtle.digest("SHA-256",encoder.encode(`hq-connect-confirm:${connectCode}`)));let text="";for(let i=0;i<LENGTH;i++)text+=ALPHABET[digest[i]%ALPHABET.length];return`${text.slice(0,3)}-${text.slice(3)}`}function normalized(value){return typeof value==="string"?value.toUpperCase().replace(/[^A-Z0-9]/g,""):""}export async function confirmationMatches(connectCode,typed){const expected=normalized(await confirmationCode(connectCode));const given=normalized(typed);if(given.length!==expected.length)return false;let diff=0;for(let i=0;i<expected.length;i++)diff|=expected.charCodeAt(i)^given.charCodeAt(i);return diff===0}
package/dist/service.d.ts CHANGED
@@ -97,4 +97,5 @@ export type ConnectPollResponse = | { status: 'pending' }
97
97
  | ({ status: 'approved' } & LicenseGrant);
98
98
  export type ApproveResult = | { ok: true, project: ConnectGrant['project'] }
99
99
  | { ok: false, reason: 'expired' | 'already_approved' }
100
+ export * from './confirm';
100
101
  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 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}}
1
+ import{isPeriod,periodOf}from"./period";import{evaluate}from"./policy";export*from"./confirm";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/dist/testing.d.ts CHANGED
@@ -14,6 +14,10 @@ export declare function createMemoryHqAdapter(options?: MemoryHqAdapterOptions):
14
14
  * import { hqServiceContract } from '@stacksjs/hq/testing'
15
15
  * hqServiceContract('bughq', async () => ({ adapter: bughqAdapter, user: await aSignedInUser() }))
16
16
  *
17
+ * A licence-only service (no `usage` on its adapter) runs the same suite: the
18
+ * approved poll must carry a licence and no keys, and it must have no usage
19
+ * route.
20
+ *
17
21
  * This module imports `bun:test`, so it is only for test files.
18
22
  */
19
23
  export declare interface HqContractHarness<User> {
package/dist/testing.js CHANGED
@@ -1 +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)}}
1
+ import{describe,expect,test}from"bun:test";import{isPeriod}from"./period";import{createHqService,createMemoryConnectCodeStore,hashConnectCode,isLicenseGrant}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()}};const licensed=harness.adapter.usage===undefined;return{...harness,service,clock,start,poll,licensed}}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 or its licence, once",async()=>{const{start,poll,service,user,adapter,licensed}=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(()=>new URL(grant.dashboardUrl)).not.toThrow();expect(isLicenseGrant(grant)).toBe(licensed);if(isLicenseGrant(grant)){expect(grant.licenseKey.length).toBeGreaterThan(0);expect(first.body).not.toHaveProperty("ingestKey");expect(first.body).not.toHaveProperty("readKey");expect(stored?.sealedGrant??"").not.toContain(grant.licenseKey)}else{expect(grant.ingestKey.length).toBeGreaterThan(0);expect(grant.readKey.length).toBeGreaterThan(0);expect(grant.readKey).not.toBe(grant.ingestKey);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("a licence-only service has no usage route",async()=>{const{service,licensed}=await boot();if(!licensed)return;expect(service.routes.map((route)=>`${route.method} ${route.path}`).sort()).toEqual(["GET /api/cli/connect/{code}","POST /api/cli/connect"]);expect(await service.handle(request("/api/v1/usage",{headers:{Authorization:"Bearer anything"}}))).toBeNull();expect((await service.usage(request("/api/v1/usage"))).status).toBe(404)});test("usage refuses a missing, malformed or wrong read key",async()=>{const{service,start,poll,user,licensed}=await boot();if(licensed)return;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,licensed}=await boot();if(licensed)return;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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/hq",
3
3
  "type": "module",
4
- "version": "0.75.568",
4
+ "version": "0.75.571",
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": [