Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 102 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,84 @@ The generated files carry a `KIT_VERSION` header and are recorded in
`src/generated/storage-kit/.storage-kit-manifest.json`. Do not hand-edit them;
regenerate instead.

## API-Key Auth (`@hasna/contracts/auth`)

Stateless, verifiable API keys for the `<app>-serve` HTTP services. A key is an
HMAC-signed compact token with the human prefix `hasna_<app>_`; the signed claims
carry the app, scopes, and TTL, so verification needs **no** database round-trip.
Only the sha256 hash is stored at rest (the secret is shown once at issue time),
and revocation is layered on top via the key store.

**Exact import + usage every `<app>-serve` service calls** (Express shown; Hono
is identical via `honoApiKey`):

```ts
import { expressApiKey, ApiKeyStore } from "@hasna/contracts/auth";
import { createCloudPoolFromEnv } from "./generated/storage-kit"; // vendored kit

const APP = "todos";
const signingSecret = process.env.HASNA_TODOS_API_SIGNING_KEY!; // shared: HASNA_API_SIGNING_KEY
const { client } = createCloudPoolFromEnv(APP); // RDS pool (Amendment A1)
const keys = new ApiKeyStore(client);
await keys.ensureSchema(); // idempotent: api_keys table

app.use(
expressApiKey({
app: APP,
signingSecret,
isRevoked: keys.isRevoked, // per-request revocation check against RDS
requiredScopes: ["todos:read"], // optional per-mount scope gate
audit: (e) => log.info("api_auth", e), // per-request AUDIT hook (allow + deny)
}),
);
// On success: req.apiKey = { kid, app, scopes, agent, claims }
```

Framework-agnostic core (for custom routers): `verifyApiKey({ app, signingSecret })`
returns `{ authenticate(headers, ctx) }`. Tokens are read from the `x-api-key`
header or `Authorization: Bearer <key>`.

**Serve env vars:**

| Env var | Purpose |
| ------------------------------ | ---------------------------------------------------------- |
| `HASNA_<APP>_API_SIGNING_KEY` | HMAC signing secret (falls back to `HASNA_API_SIGNING_KEY`) |
| `HASNA_<APP>_DATABASE_URL` | RDS URL for the `api_keys` store (revocation lookups) |

**Client env vars (self_hosted mode):** `<APP>_API_URL` + `<APP>_API_KEY` — never a DSN.

Scope grammar is `<app>:<action>` with wildcards (`*`, `<app>:*`, `*:<action>`).

### Issuing keys

```bash
# Mint a scoped key: stores the hashed record in RDS, prints the secret ONCE.
contracts issue-key --app todos --agent worker-1 --scopes 'todos:read,todos:write'

# Bootstrap admin key (scopes default to '<app>:*', agent 'bootstrap'):
contracts issue-key --app todos --bootstrap

# Print secret + hash without persisting (e.g. offline signing):
contracts issue-key --app todos --scopes 'todos:read' --no-store --json
```

Signing secret is read from `HASNA_<APP>_API_SIGNING_KEY` (then `HASNA_API_SIGNING_KEY`);
the record store uses `HASNA_<APP>_DATABASE_URL`. Generate a signing secret with
`openssl rand -hex 32`. Revoke with `store.revoke(kid)`.

## SDK from OpenAPI (`@hasna/contracts/sdk`)

`generateSdkFromOpenApi(spec)` turns an `<app>-serve` OpenAPI 3 document into a
typed, dependency-free `fetch` client plus interfaces from `components.schemas`.
The generated client sends the API key as `x-api-key`, so a self_hosted consumer
only needs `<APP>_API_URL` + `<APP>_API_KEY`.

```ts
import { generateSdkFromOpenApi } from "@hasna/contracts/sdk";
const { code, operations, warnings } = generateSdkFromOpenApi(openapiDoc, { className: "TodosClient" });
// write `code` to the app SDK package's client.ts
```

## TypeScript

```ts
Expand Down Expand Up @@ -199,7 +277,10 @@ defaults are applied; output aliases such as `EvidenceRef` describe parsed data.
resource refs, evidence, and proof refs.
- `hasna.app_cloud_manifest.v1`: app-owned cloud boundary declaration for a
package that uses its own cloud resources, local cache, and conflict policy
without depending on shared `@hasna/cloud` or `open-cloud` runtimes.
without depending on shared `@hasna/cloud` or `open-cloud` runtimes. This is
NOT an identity schema: canonical app identity lives in `hasna.app.v1`, and
this v1 manifest keeps `appId` as a non-empty reference string for
compatibility; new manifests should use the stable `hasna.app.v1` slug.
- `hasna.no_cloud_evidence_pack.v1`: prepublish/CI evidence pack for package
manifest, lockfile, source/runtime config, packed artifact, published
metadata, and app-cloud-manifest scans.
Expand All @@ -208,6 +289,26 @@ defaults are applied; output aliases such as `EvidenceRef` describe parsed data.
tracked kit version, declared bins, and the `local | cloud` storage boundary.
See `CONTRACT.md` for the normative spec and `contracts repo-conformance` /
`runRepoConformance` for the self-check kit.
- `hasna.app.v1`: canonical app identity for the distribution apps plan —
stable `appId` slug, `npmName`, `repoFolder`, `githubUrl`, `projectSlug`,
surfaces (`bins`, optional `mcp`/`http`), lifecycle
(`active|stub|deprecated|archived`), and release channel. All other
distribution documents reference apps by `appId` only.
- `hasna.release.v1`: publish receipt for one app package version — `appId`,
`package`, semver `version`, `gitSha`, `publishedAt`, publish path
(`skill|ci|backfilled`), optional deferred `changelogRef`, and publish
evidence (required unless backfilled).
- `hasna.rollout_record.v1`: per-machine rollout receipt — `appId`, `package`,
`version`, `machine`, action (`install|update|rollback|freeze-blocked`),
contract-status `result`, `verifiedBy` with at least one verifier field for
successful install/update records (`cliVersion` or checked `mcpHealth`), and
`at`.
- `hasna.announcement.v1`: release/campaign announcement receipt — `campaignId`,
optional `appId` and `releaseRef`, per-channel delivery statuses, an
`audienceRef` (resource kind `audience`), and `sentAt`.
- `hasna.audience.v1`: named audience definition — `audienceId`, tag/attribute/
group predicates with `all|any` matching, consent policy
(`opt_in|opt_out|transactional|none`), and `suppressionSyncedAt`.

Every top-level contract includes a literal `schema` field. Consumers should
reject objects whose embedded schema does not match the validator being used.
Expand Down
19 changes: 19 additions & 0 deletions examples/announcement.invalid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"schema": "hasna.announcement.v1",
"id": "announcement_bad_audience_ref_kind",
"createdAt": "2026-07-06T11:00:00.000Z",
"campaignId": "campaign_release_open_todos_0_11_63",
"appId": "open-todos",
"channels": [
{
"channel": "email",
"status": "sent",
"deliveredAt": "2026-07-06T11:05:00.000Z"
}
],
"audienceRef": {
"kind": "task",
"id": "audience_oss_operators"
},
"sentAt": "2026-07-06T11:05:00.000Z"
}
30 changes: 30 additions & 0 deletions examples/announcement.valid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"schema": "hasna.announcement.v1",
"id": "announcement_open_todos_0_11_63",
"createdAt": "2026-07-06T11:00:00.000Z",
"campaignId": "campaign_release_open_todos_0_11_63",
"appId": "open-todos",
"releaseRef": {
"kind": "release",
"id": "release_open_todos_0_11_63",
"sourcePackage": "@hasna/contracts",
"externalId": "release_open_todos_0_11_63"
},
"channels": [
{
"channel": "email",
"status": "sent",
"deliveredAt": "2026-07-06T11:05:00.000Z"
},
{
"channel": "telegram",
"status": "skipped",
"detail": "audience has no telegram members"
}
],
"audienceRef": {
"kind": "audience",
"id": "audience_oss_operators"
},
"sentAt": "2026-07-06T11:05:00.000Z"
}
15 changes: 15 additions & 0 deletions examples/app.invalid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"schema": "hasna.app.v1",
"id": "app_open_todos_duplicate_bins",
"createdAt": "2026-07-06T08:00:00.000Z",
"appId": "open-todos",
"npmName": "@hasna/todos",
"repoFolder": "open-todos",
"githubUrl": "https://github.com/hasna/todos",
"projectSlug": "open-todos",
"surfaces": {
"bins": ["todos", "todos"]
},
"lifecycle": "active",
"releaseChannel": "stable"
}
25 changes: 25 additions & 0 deletions examples/app.valid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"schema": "hasna.app.v1",
"id": "app_open_todos",
"createdAt": "2026-07-06T08:00:00.000Z",
"appId": "open-todos",
"npmName": "@hasna/todos",
"repoFolder": "open-todos",
"githubUrl": "https://github.com/hasna/todos",
"projectSlug": "open-todos",
"surfaces": {
"bins": ["todos", "todos-cli", "todos-mcp"],
"mcp": {
"transport": "http",
"bin": "todos-mcp"
},
"http": {
"healthPath": "/health",
"port": 4310
}
},
"lifecycle": "active",
"releaseChannel": "stable",
"summary": "Task and plan tracking for Hasna agents",
"tags": ["distribution", "oss"]
}
19 changes: 19 additions & 0 deletions examples/audience.invalid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"schema": "hasna.audience.v1",
"id": "audience_missing_attribute_key",
"createdAt": "2026-07-06T07:00:00.000Z",
"audienceId": "oss-operators",
"name": "OSS fleet operators",
"definition": {
"match": "all",
"predicates": [
{
"kind": "attribute",
"op": "eq",
"value": "spark01"
}
]
},
"consentPolicy": "opt_in",
"suppressionSyncedAt": null
}
25 changes: 25 additions & 0 deletions examples/audience.valid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"schema": "hasna.audience.v1",
"id": "audience_oss_operators",
"createdAt": "2026-07-06T07:00:00.000Z",
"audienceId": "oss-operators",
"name": "OSS fleet operators",
"definition": {
"match": "all",
"predicates": [
{
"kind": "tag",
"op": "eq",
"value": "fleet-operator"
},
{
"kind": "attribute",
"key": "machine",
"op": "in",
"values": ["spark01", "spark02", "apple01"]
}
]
},
"consentPolicy": "opt_in",
"suppressionSyncedAt": "2026-07-06T06:30:00.000Z"
}
12 changes: 12 additions & 0 deletions examples/release.invalid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"schema": "hasna.release.v1",
"id": "release_missing_publish_evidence",
"createdAt": "2026-07-06T09:00:00.000Z",
"appId": "open-todos",
"package": "@hasna/todos",
"version": "0.11.63",
"gitSha": "9fceb02d0ae598e95dc970b74767f19372d61af8",
"publishedAt": "2026-07-06T09:00:00.000Z",
"publishPath": "skill",
"evidenceRefs": []
}
19 changes: 19 additions & 0 deletions examples/release.valid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"schema": "hasna.release.v1",
"id": "release_open_todos_0_11_63",
"createdAt": "2026-07-06T09:00:00.000Z",
"appId": "open-todos",
"package": "@hasna/todos",
"version": "0.11.63",
"gitSha": "9fceb02d0ae598e95dc970b74767f19372d61af8",
"publishedAt": "2026-07-06T09:00:00.000Z",
"publishPath": "skill",
"evidenceRefs": [
{
"id": "ev_publish_open_todos_0_11_63",
"kind": "command_output",
"uri": "artifact://releases/open-todos/0.11.63/publish-output.txt",
"summary": "bun publish output"
}
]
}
13 changes: 13 additions & 0 deletions examples/rollout-record.invalid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"schema": "hasna.rollout_record.v1",
"id": "rollout_update_empty_verified_by",
"createdAt": "2026-07-06T10:00:00.000Z",
"appId": "open-todos",
"package": "@hasna/todos",
"version": "0.11.63",
"machine": "spark01",
"action": "update",
"result": "succeeded",
"verifiedBy": {},
"at": "2026-07-06T10:00:00.000Z"
}
23 changes: 23 additions & 0 deletions examples/rollout-record.valid.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"schema": "hasna.rollout_record.v1",
"id": "rollout_open_todos_spark01_0_11_63",
"createdAt": "2026-07-06T10:00:00.000Z",
"appId": "open-todos",
"package": "@hasna/todos",
"version": "0.11.63",
"machine": "spark01",
"action": "update",
"result": "succeeded",
"verifiedBy": {
"cliVersion": "0.11.63",
"mcpHealth": "ok"
},
"at": "2026-07-06T10:00:00.000Z",
"evidenceRefs": [
{
"id": "ev_rollout_open_todos_spark01",
"kind": "command_output",
"uri": "artifact://rollouts/open-todos/spark01/install-output.txt"
}
]
}
14 changes: 11 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hasna/contracts",
"version": "0.4.0",
"version": "0.4.1",
"description": "Shared schemas and validators for Hasna open-source agent infrastructure contracts.",
"type": "module",
"license": "Apache-2.0",
Expand Down Expand Up @@ -53,6 +53,14 @@
"types": "./dist/kit/generate.d.ts",
"import": "./dist/kit/generate.js"
},
"./auth": {
"types": "./dist/auth/index.d.ts",
"import": "./dist/auth/index.js"
},
"./sdk": {
"types": "./dist/sdk/generate.d.ts",
"import": "./dist/sdk/generate.js"
},
"./hasna.contract.schema.json": "./dist/hasna.contract.schema.json"
},
"files": [
Expand All @@ -66,12 +74,12 @@
"LICENSE"
],
"scripts": {
"build": "rm -rf dist && bun build src/index.ts src/schemas.ts src/validators.ts src/no-cloud.ts src/mode.ts src/service-contract.ts src/conformance.ts src/kit/generate.ts --outdir dist --target bun && bun build src/cli/index.ts --outdir dist/cli --target bun && cp src/hasna.contract.schema.json dist/hasna.contract.schema.json && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
"build": "rm -rf dist && bun build src/index.ts src/schemas.ts src/validators.ts src/no-cloud.ts src/mode.ts src/service-contract.ts src/conformance.ts src/kit/generate.ts src/auth/index.ts src/sdk/generate.ts --root src --outdir dist --target bun && bun build src/cli/index.ts --outdir dist/cli --target bun && cp src/hasna.contract.schema.json dist/hasna.contract.schema.json && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
"typecheck": "tsc --noEmit",
"test": "bun test",
"lint": "tsc --noEmit",
"conformance": "bun run src/cli/index.ts conformance examples",
"verify:release": "bun run typecheck && bun test && bun run conformance && bun run build && bun run smoke:dist && bun run pack:check",
"verify:release": "rm -rf dist && bun run typecheck && bun test && bun run conformance && bun run build && bun run smoke:dist && bun run pack:check",
"smoke:dist": "bun scripts/smoke-dist.ts",
"pack:check": "bun pm pack --dry-run --ignore-scripts",
"dev:cli": "bun run src/cli/index.ts",
Expand Down
11 changes: 11 additions & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Public surface of the Hasna API-key auth kit.
//
// Stateless, HMAC-signed, verifiable keys (prefix `hasna_<app>_`), a scope
// grammar (`<app>:<action>` + wildcards), TTL, a hashed-at-rest record store
// with a revocation list, a per-request audit hook, and an Express/Hono-agnostic
// `verifyApiKey()` middleware.

export * from "./scopes.js";
export * from "./keys.js";
export * from "./store.js";
export * from "./middleware.js";
Loading
Loading