From 2dc7de12cbc014f382a43db4a6407ad3c9f0b36e Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Mon, 6 Jul 2026 14:02:18 +0300 Subject: [PATCH 1/3] feat: add distribution contract schemas (app, release, rollout_record, announcement, audience) - hasna.app.v1: canonical app identity (appId slug, npmName, repoFolder, githubUrl, projectSlug, surfaces {bins, mcp?, http?}, lifecycle, releaseChannel) - hasna.release.v1: publish receipt with publishPath (skill|ci|backfilled), deferred-legal changelogRef, and evidence required unless backfilled - hasna.rollout_record.v1: per-machine rollout receipt with install|update|rollback|freeze-blocked action/result coupling and verifiedBy {cliVersion?, mcpHealth?} - hasna.announcement.v1: campaign receipt with per-channel delivery status and audienceRef - hasna.audience.v1: tag/attribute/group predicate definition with consent policy and suppressionSyncedAt - resolve identity overlap: hasna.app_cloud_manifest.v1 now references hasna.app.v1 by AppIdSchema appId and is documented as non-identity - extend ResourceKindSchema with app/release/rollout/announcement/audience/ feedback kinds; register schemas, fixtures, and conformance cases --- README.md | 23 +- examples/announcement.invalid.json | 19 ++ examples/announcement.valid.json | 30 +++ examples/app.invalid.json | 15 ++ examples/app.valid.json | 25 ++ examples/audience.invalid.json | 19 ++ examples/audience.valid.json | 25 ++ examples/release.invalid.json | 12 + examples/release.valid.json | 19 ++ examples/rollout-record.invalid.json | 15 ++ examples/rollout-record.valid.json | 23 ++ src/schemas.ts | 354 ++++++++++++++++++++++++++- tests/examples.test.ts | 5 + tests/schemas.test.ts | 249 +++++++++++++++++++ 14 files changed, 829 insertions(+), 4 deletions(-) create mode 100644 examples/announcement.invalid.json create mode 100644 examples/announcement.valid.json create mode 100644 examples/app.invalid.json create mode 100644 examples/app.valid.json create mode 100644 examples/audience.invalid.json create mode 100644 examples/audience.valid.json create mode 100644 examples/release.invalid.json create mode 100644 examples/release.valid.json create mode 100644 examples/rollout-record.invalid.json create mode 100644 examples/rollout-record.valid.json diff --git a/README.md b/README.md index 8103f67..265bf4c 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,9 @@ 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 manifest references it by the same stable `appId` 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. @@ -208,6 +210,25 @@ 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`, optional `verifiedBy` (`cliVersion`, `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. diff --git a/examples/announcement.invalid.json b/examples/announcement.invalid.json new file mode 100644 index 0000000..16452eb --- /dev/null +++ b/examples/announcement.invalid.json @@ -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" +} diff --git a/examples/announcement.valid.json b/examples/announcement.valid.json new file mode 100644 index 0000000..8000463 --- /dev/null +++ b/examples/announcement.valid.json @@ -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" +} diff --git a/examples/app.invalid.json b/examples/app.invalid.json new file mode 100644 index 0000000..404fd83 --- /dev/null +++ b/examples/app.invalid.json @@ -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" +} diff --git a/examples/app.valid.json b/examples/app.valid.json new file mode 100644 index 0000000..2d3890b --- /dev/null +++ b/examples/app.valid.json @@ -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"] +} diff --git a/examples/audience.invalid.json b/examples/audience.invalid.json new file mode 100644 index 0000000..da25c9b --- /dev/null +++ b/examples/audience.invalid.json @@ -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 +} diff --git a/examples/audience.valid.json b/examples/audience.valid.json new file mode 100644 index 0000000..e2bb066 --- /dev/null +++ b/examples/audience.valid.json @@ -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" +} diff --git a/examples/release.invalid.json b/examples/release.invalid.json new file mode 100644 index 0000000..73150e3 --- /dev/null +++ b/examples/release.invalid.json @@ -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": [] +} diff --git a/examples/release.valid.json b/examples/release.valid.json new file mode 100644 index 0000000..7fcc36d --- /dev/null +++ b/examples/release.valid.json @@ -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" + } + ] +} diff --git a/examples/rollout-record.invalid.json b/examples/rollout-record.invalid.json new file mode 100644 index 0000000..a7cdff9 --- /dev/null +++ b/examples/rollout-record.invalid.json @@ -0,0 +1,15 @@ +{ + "schema": "hasna.rollout_record.v1", + "id": "rollout_freeze_blocked_wrong_result", + "createdAt": "2026-07-06T10:00:00.000Z", + "appId": "open-todos", + "package": "@hasna/todos", + "version": "0.11.63", + "machine": "spark01", + "action": "freeze-blocked", + "result": "succeeded", + "verifiedBy": { + "cliVersion": "0.11.63" + }, + "at": "2026-07-06T10:00:00.000Z" +} diff --git a/examples/rollout-record.valid.json b/examples/rollout-record.valid.json new file mode 100644 index 0000000..04d789f --- /dev/null +++ b/examples/rollout-record.valid.json @@ -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" + } + ] +} diff --git a/src/schemas.ts b/src/schemas.ts index 6b9103c..33c5f02 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -24,7 +24,12 @@ export const SCHEMA_IDS = { scaffoldInstallRecord: "hasna.scaffold_install_record.v1", appCloudManifest: "hasna.app_cloud_manifest.v1", noCloudEvidencePack: "hasna.no_cloud_evidence_pack.v1", - serviceContract: "hasna.service_contract.v1" + serviceContract: "hasna.service_contract.v1", + app: "hasna.app.v1", + release: "hasna.release.v1", + rolloutRecord: "hasna.rollout_record.v1", + announcement: "hasna.announcement.v1", + audience: "hasna.audience.v1" } as const; export const SchemaIdSchema = z @@ -171,6 +176,12 @@ export const ResourceKindSchema = z.enum([ "cost", "alert", "incident", + "app", + "release", + "rollout", + "announcement", + "audience", + "feedback", "unknown" ]); export type ResourceKind = z.infer; @@ -1082,6 +1093,319 @@ export const ScaffoldInstallRecordSchema = contractBaseSchema(SCHEMA_IDS.scaffol }); export type ScaffoldInstallRecord = z.infer; +// --------------------------------------------------------------------------- +// Distribution contracts (Hasna distribution apps plan) +// +// `hasna.app.v1` is the SINGLE canonical app-identity contract. Every other +// distribution document (releases, rollout records, announcements) and the +// pre-existing `hasna.app_cloud_manifest.v1` reference an app by its stable +// `appId` slug instead of re-declaring identity fields. +// --------------------------------------------------------------------------- + +/** Stable lowercase dashed app identity slug, e.g. `open-todos`. */ +export const AppIdSchema = z + .string() + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "App ids must be lowercase dashed identifiers"); +export type AppId = z.infer; + +/** npm package name, scoped or unscoped, e.g. `@hasna/todos`. */ +export const NpmPackageNameSchema = z + .string() + .regex(/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/, "Must be a valid npm package name"); +export type NpmPackageName = z.infer; + +/** Semver version string, e.g. `1.2.3`, `1.2.3-beta.1`. */ +export const SemverSchema = z + .string() + .regex( + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/, + "Must be a semver version" + ); +export type Semver = z.infer; + +/** Lowercase git commit sha, abbreviated (>=7) or full (40). */ +export const GitShaSchema = z.string().regex(/^[0-9a-f]{7,40}$/, "Must be a lowercase git sha (7-40 hex chars)"); +export type GitSha = z.infer; + +export const GithubUrlSchema = NonEmptyStringSchema.refine( + (value) => value.startsWith("https://github.com/") || value.startsWith("git+https://github.com/"), + "GitHub URLs must start with https://github.com/ or git+https://github.com/" +); + +export const AppLifecycleSchema = z.enum(["active", "stub", "deprecated", "archived"]); +export type AppLifecycle = z.infer; + +export const ReleaseChannelSchema = z.enum(["stable", "beta", "canary", "internal"]); +export type ReleaseChannel = z.infer; + +export const AppMcpSurfaceSchema = z + .object({ + transport: z.enum(["http", "stdio"]).default("http"), + bin: z.string().min(1).optional(), + url: UriSchema.optional() + }) + .strict(); +export type AppMcpSurface = z.infer; + +export const AppHttpSurfaceSchema = z + .object({ + healthPath: z.string().min(1).default("/health"), + port: z.number().int().positive().optional(), + baseUrl: UriSchema.optional() + }) + .strict(); +export type AppHttpSurface = z.infer; + +export const AppSurfacesSchema = z + .object({ + bins: z.array(z.string().min(1)).default([]), + mcp: AppMcpSurfaceSchema.optional(), + http: AppHttpSurfaceSchema.optional() + }) + .strict(); +export type AppSurfaces = z.infer; + +export const AppSchema = contractBaseSchema(SCHEMA_IDS.app) + .extend({ + appId: AppIdSchema, + npmName: NpmPackageNameSchema, + repoFolder: AppIdSchema, + githubUrl: GithubUrlSchema, + projectSlug: ProjectSlugSchema, + surfaces: AppSurfacesSchema.default({}), + lifecycle: AppLifecycleSchema, + releaseChannel: ReleaseChannelSchema.default("stable"), + summary: z.string().min(1).optional(), + tags: TagsSchema + }) + .strict() + .superRefine((value, ctx) => { + const seenBins = new Set(); + for (const [index, bin] of value.surfaces.bins.entries()) { + if (seenBins.has(bin)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "App surface bins must be unique", + path: ["surfaces", "bins", index] + }); + } + seenBins.add(bin); + } + }); +export type App = z.infer; + +export const PublishPathSchema = z.enum(["skill", "ci", "backfilled"]); +export type PublishPath = z.infer; + +export const ReleaseSchema = contractBaseSchema(SCHEMA_IDS.release) + .extend({ + appId: AppIdSchema, + package: NpmPackageNameSchema, + version: SemverSchema, + gitSha: GitShaSchema, + publishedAt: TimestampSchema, + publishPath: PublishPathSchema, + /** Deferred changelog refs are legal: omit until the changelog entry exists. */ + changelogRef: ResourcePointerSchema.optional(), + evidenceRefs: z.array(EvidencePointerSchema).default([]) + }) + .strict() + .superRefine((value, ctx) => { + if (value.publishPath !== "backfilled" && value.evidenceRefs.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "skill and ci releases require publish evidence; only backfilled releases may omit it", + path: ["evidenceRefs"] + }); + } + }); +export type Release = z.infer; + +export const RolloutActionSchema = z.enum(["install", "update", "rollback", "freeze-blocked"]); +export type RolloutAction = z.infer; + +export const RolloutVerificationSchema = z + .object({ + cliVersion: z.string().min(1).optional(), + mcpHealth: z.enum(["ok", "degraded", "unavailable", "not_checked"]).optional() + }) + .strict(); +export type RolloutVerification = z.infer; + +export const RolloutRecordSchema = contractBaseSchema(SCHEMA_IDS.rolloutRecord) + .extend({ + appId: AppIdSchema, + package: NpmPackageNameSchema, + version: SemverSchema, + machine: NonEmptyStringSchema, + action: RolloutActionSchema, + result: ContractStatusSchema, + verifiedBy: RolloutVerificationSchema.optional(), + at: TimestampSchema, + evidenceRefs: z.array(EvidencePointerSchema).default([]) + }) + .strict() + .superRefine((value, ctx) => { + if (value.action === "freeze-blocked" && value.result !== "blocked" && value.result !== "skipped") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "freeze-blocked rollout records must report result blocked or skipped", + path: ["result"] + }); + } + if ((value.action === "install" || value.action === "update") && value.result === "succeeded" && !value.verifiedBy) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Succeeded install/update rollout records require verifiedBy", + path: ["verifiedBy"] + }); + } + }); +export type RolloutRecord = z.infer; + +export const AnnouncementChannelKindSchema = z.enum([ + "email", + "telegram", + "slack", + "discord", + "x", + "blog", + "rss", + "webhook", + "github", + "other" +]); +export type AnnouncementChannelKind = z.infer; + +export const AnnouncementDeliveryStatusSchema = z.enum([ + "pending", + "queued", + "sent", + "failed", + "skipped", + "suppressed" +]); +export type AnnouncementDeliveryStatus = z.infer; + +export const AnnouncementChannelSchema = z + .object({ + channel: AnnouncementChannelKindSchema, + status: AnnouncementDeliveryStatusSchema, + deliveredAt: TimestampSchema.optional(), + detail: z.string().min(1).optional() + }) + .strict() + .superRefine((value, ctx) => { + if (value.status === "sent" && !value.deliveredAt) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Sent announcement channels require deliveredAt", + path: ["deliveredAt"] + }); + } + if (value.status === "failed" && !value.detail) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Failed announcement channels require detail", + path: ["detail"] + }); + } + }); +export type AnnouncementChannel = z.infer; + +export const AnnouncementSchema = contractBaseSchema(SCHEMA_IDS.announcement) + .extend({ + campaignId: NonEmptyStringSchema, + appId: AppIdSchema.optional(), + releaseRef: ResourcePointerSchema.optional(), + channels: z.array(AnnouncementChannelSchema).min(1), + audienceRef: ResourcePointerSchema, + sentAt: TimestampSchema + }) + .strict() + .superRefine((value, ctx) => { + if (value.releaseRef && value.releaseRef.kind !== "release") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Announcement releaseRef must use resource kind release", + path: ["releaseRef", "kind"] + }); + } + if (value.audienceRef.kind !== "audience") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Announcement audienceRef must use resource kind audience", + path: ["audienceRef", "kind"] + }); + } + }); +export type Announcement = z.infer; + +export const AudiencePredicateKindSchema = z.enum(["tag", "attribute", "group"]); +export type AudiencePredicateKind = z.infer; + +export const AudiencePredicateOpSchema = z.enum(["eq", "neq", "in", "not_in", "exists", "not_exists"]); +export type AudiencePredicateOp = z.infer; + +const AudiencePredicateValueSchema = z.union([z.string(), z.number(), z.boolean()]); + +export const AudiencePredicateSchema = z + .object({ + kind: AudiencePredicateKindSchema, + /** Attribute key (required for attribute predicates), e.g. `machine`. */ + key: z.string().min(1).optional(), + op: AudiencePredicateOpSchema.default("eq"), + value: AudiencePredicateValueSchema.optional(), + values: z.array(AudiencePredicateValueSchema).default([]) + }) + .strict() + .superRefine((value, ctx) => { + if (value.kind === "attribute" && !value.key) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Attribute predicates require key", + path: ["key"] + }); + } + if ((value.op === "eq" || value.op === "neq") && value.value === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "eq/neq predicates require value", + path: ["value"] + }); + } + if ((value.op === "in" || value.op === "not_in") && value.values.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "in/not_in predicates require values", + path: ["values"] + }); + } + }); +export type AudiencePredicate = z.infer; + +export const AudienceDefinitionSchema = z + .object({ + match: z.enum(["all", "any"]).default("all"), + predicates: z.array(AudiencePredicateSchema).min(1) + }) + .strict(); +export type AudienceDefinition = z.infer; + +export const ConsentPolicySchema = z.enum(["opt_in", "opt_out", "transactional", "none"]); +export type ConsentPolicy = z.infer; + +export const AudienceSchema = contractBaseSchema(SCHEMA_IDS.audience) + .extend({ + audienceId: AppIdSchema, + name: NonEmptyStringSchema, + definition: AudienceDefinitionSchema, + consentPolicy: ConsentPolicySchema, + suppressionSyncedAt: OptionalTimestampSchema + }) + .strict(); +export type Audience = z.infer; + export const FORBIDDEN_SHARED_CLOUD_RUNTIMES = ["@hasna/cloud", "open-cloud"] as const; export const AppCloudProviderSchema = z.enum([ @@ -1125,11 +1449,15 @@ export const AppCloudResourceSchema = z .strict(); export type AppCloudResource = z.infer; +// `hasna.app_cloud_manifest.v1` is NOT an identity schema. Canonical app +// identity lives in `hasna.app.v1`; this manifest references that document by +// its stable `appId` slug and only declares the app's cloud storage boundary. export const AppCloudManifestSchema = contractBaseSchema(SCHEMA_IDS.appCloudManifest) .extend({ packageName: z.string().min(1), packageVersion: z.string().min(1).optional(), - appId: z.string().min(1), + /** Stable app identity slug; references the app's `hasna.app.v1` document. */ + appId: AppIdSchema, repository: ResourcePointerSchema.optional(), storageMode: z.enum(["local_only", "app_owned_cloud", "hybrid_local_cache", "external_service"]), cloudBoundary: z.enum(["none", "app_owned", "external_service", "local_cache"]), @@ -1731,7 +2059,12 @@ export const ContractSchemaRegistry = { [SCHEMA_IDS.scaffoldInstallRecord]: ScaffoldInstallRecordSchema, [SCHEMA_IDS.appCloudManifest]: AppCloudManifestSchema, [SCHEMA_IDS.noCloudEvidencePack]: NoCloudEvidencePackSchema, - [SCHEMA_IDS.serviceContract]: ServiceContractManifestSchema + [SCHEMA_IDS.serviceContract]: ServiceContractManifestSchema, + [SCHEMA_IDS.app]: AppSchema, + [SCHEMA_IDS.release]: ReleaseSchema, + [SCHEMA_IDS.rolloutRecord]: RolloutRecordSchema, + [SCHEMA_IDS.announcement]: AnnouncementSchema, + [SCHEMA_IDS.audience]: AudienceSchema } as const; export type KnownSchemaId = keyof typeof ContractSchemaRegistry; @@ -1758,6 +2091,11 @@ export type ContractBySchemaId = { [SCHEMA_IDS.appCloudManifest]: AppCloudManifest; [SCHEMA_IDS.noCloudEvidencePack]: NoCloudEvidencePack; [SCHEMA_IDS.serviceContract]: ServiceContractManifest; + [SCHEMA_IDS.app]: App; + [SCHEMA_IDS.release]: Release; + [SCHEMA_IDS.rolloutRecord]: RolloutRecord; + [SCHEMA_IDS.announcement]: Announcement; + [SCHEMA_IDS.audience]: Audience; }; export type ActorRefInput = z.input; @@ -1781,6 +2119,11 @@ export type ScaffoldInstallRecordInput = z.input; export type NoCloudEvidencePackInput = z.input; export type ServiceContractManifestInput = z.input; +export type AppInput = z.input; +export type ReleaseInput = z.input; +export type RolloutRecordInput = z.input; +export type AnnouncementInput = z.input; +export type AudienceInput = z.input; export type ActorPointerInput = z.input; export type ResourcePointerInput = z.input; export type EvidencePointerInput = z.input; @@ -1807,4 +2150,9 @@ export type ContractInputBySchemaId = { [SCHEMA_IDS.appCloudManifest]: AppCloudManifestInput; [SCHEMA_IDS.noCloudEvidencePack]: NoCloudEvidencePackInput; [SCHEMA_IDS.serviceContract]: ServiceContractManifestInput; + [SCHEMA_IDS.app]: AppInput; + [SCHEMA_IDS.release]: ReleaseInput; + [SCHEMA_IDS.rolloutRecord]: RolloutRecordInput; + [SCHEMA_IDS.announcement]: AnnouncementInput; + [SCHEMA_IDS.audience]: AudienceInput; }; diff --git a/tests/examples.test.ts b/tests/examples.test.ts index 6a6f1c2..a9e5186 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -5,8 +5,13 @@ import { ContractSchemaRegistry, SCHEMA_IDS, type KnownSchemaId, validateContrac const examplesDir = join(import.meta.dir, "..", "examples"); const expectedInvalidIssuePaths: Record = { + "announcement.invalid.json": ["audienceRef.kind"], + "app.invalid.json": ["surfaces.bins.1"], "app-cloud-manifest.invalid.json": ["cloudResources.0.ownerPackage", "dependencies", "forbiddenSharedRuntimes", "packageName"], + "audience.invalid.json": ["definition.predicates.0.key"], "integration-ref.invalid.json": ["uri"], + "release.invalid.json": ["evidenceRefs"], + "rollout-record.invalid.json": ["result"], "no-cloud-evidence-pack.invalid.json": ["checks", "checks", "findings"], "project-manifest.invalid.json": ["slug"], "project-panel.invalid.json": ["stateReason"], diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts index b616915..88d211f 100644 --- a/tests/schemas.test.ts +++ b/tests/schemas.test.ts @@ -933,3 +933,252 @@ describe("core schemas", () => { expect(missingEvidence.success).toBe(false); }); }); + +describe("distribution schemas", () => { + const validApp = { + schema: SCHEMA_IDS.app, + id: "app_open_todos", + createdAt, + appId: "open-todos", + npmName: "@hasna/todos", + repoFolder: "open-todos", + githubUrl: "https://github.com/hasna/todos", + projectSlug: "open-todos", + surfaces: { + bins: ["todos", "todos-mcp"], + mcp: { transport: "http", bin: "todos-mcp" }, + http: { healthPath: "/health", port: 4310 } + }, + lifecycle: "active", + releaseChannel: "stable" + } as const; + + const validRelease = { + schema: SCHEMA_IDS.release, + id: "release_open_todos_0_11_63", + createdAt, + appId: "open-todos", + package: "@hasna/todos", + version: "0.11.63", + gitSha: "9fceb02d0ae598e95dc970b74767f19372d61af8", + publishedAt: createdAt, + publishPath: "skill", + evidenceRefs: [evidencePointer] + } as const; + + const validRollout = { + schema: SCHEMA_IDS.rolloutRecord, + id: "rollout_open_todos_spark01", + createdAt, + appId: "open-todos", + package: "@hasna/todos", + version: "0.11.63", + machine: "spark01", + action: "update", + result: "succeeded", + verifiedBy: { cliVersion: "0.11.63", mcpHealth: "ok" }, + at: createdAt + } as const; + + test("validates canonical app identity and applies defaults", () => { + const app = parseContract(SCHEMA_IDS.app, validApp); + expect(app.appId).toBe("open-todos"); + expect(app.surfaces.bins).toEqual(["todos", "todos-mcp"]); + expect(app.tags).toEqual([]); + + const minimal = parseContract(SCHEMA_IDS.app, { + schema: SCHEMA_IDS.app, + id: "app_open_uptime", + createdAt, + appId: "open-uptime", + npmName: "@hasna/uptime", + repoFolder: "open-uptime", + githubUrl: "git+https://github.com/hasna/uptime.git", + projectSlug: "open-uptime", + lifecycle: "stub" + }); + expect(minimal.releaseChannel).toBe("stable"); + expect(minimal.surfaces).toEqual({ bins: [] }); + }); + + test("rejects apps with bad slugs, non-github urls, or duplicate bins", () => { + expect(validateContract(SCHEMA_IDS.app, { ...validApp, appId: "Open Todos" }).success).toBe(false); + expect(validateContract(SCHEMA_IDS.app, { ...validApp, githubUrl: "https://gitlab.com/hasna/todos" }).success).toBe(false); + expect(validateContract(SCHEMA_IDS.app, { ...validApp, lifecycle: "retired" }).success).toBe(false); + + const duplicateBins = validateContract(SCHEMA_IDS.app, { + ...validApp, + surfaces: { bins: ["todos", "todos"] } + }); + expect(duplicateBins.success).toBe(false); + if (!duplicateBins.success) { + expect(duplicateBins.error.issues.map((issue) => issue.path.join("."))).toContain("surfaces.bins.1"); + } + }); + + test("validates releases and allows deferred changelog refs", () => { + const release = parseContract(SCHEMA_IDS.release, validRelease); + expect(release.changelogRef).toBeUndefined(); + expect(release.publishPath).toBe("skill"); + + const withChangelog = parseContract(SCHEMA_IDS.release, { + ...validRelease, + changelogRef: { kind: "document", id: "changelog_open_todos_0_11_63", uri: "https://github.com/hasna/todos/blob/main/CHANGELOG.md" } + }); + expect(withChangelog.changelogRef?.id).toBe("changelog_open_todos_0_11_63"); + }); + + test("requires publish evidence unless the release is backfilled", () => { + const missingEvidence = validateContract(SCHEMA_IDS.release, { ...validRelease, evidenceRefs: [] }); + expect(missingEvidence.success).toBe(false); + if (!missingEvidence.success) { + expect(missingEvidence.error.issues.map((issue) => issue.path.join("."))).toContain("evidenceRefs"); + } + + const backfilled = validateContract(SCHEMA_IDS.release, { + ...validRelease, + publishPath: "backfilled", + evidenceRefs: [] + }); + expect(backfilled.success).toBe(true); + + expect(validateContract(SCHEMA_IDS.release, { ...validRelease, gitSha: "not-a-sha" }).success).toBe(false); + expect(validateContract(SCHEMA_IDS.release, { ...validRelease, version: "v1.2" }).success).toBe(false); + }); + + test("validates rollout records and enforces action/result coupling", () => { + const rollout = parseContract(SCHEMA_IDS.rolloutRecord, validRollout); + expect(rollout.verifiedBy?.mcpHealth).toBe("ok"); + + const freezeBlockedOk = validateContract(SCHEMA_IDS.rolloutRecord, { + ...validRollout, + id: "rollout_freeze_blocked", + action: "freeze-blocked", + result: "blocked", + verifiedBy: undefined + }); + expect(freezeBlockedOk.success).toBe(true); + + const freezeBlockedBad = validateContract(SCHEMA_IDS.rolloutRecord, { + ...validRollout, + action: "freeze-blocked", + result: "succeeded" + }); + expect(freezeBlockedBad.success).toBe(false); + if (!freezeBlockedBad.success) { + expect(freezeBlockedBad.error.issues.map((issue) => issue.path.join("."))).toContain("result"); + } + + const unverifiedSuccess = validateContract(SCHEMA_IDS.rolloutRecord, { + ...validRollout, + verifiedBy: undefined + }); + expect(unverifiedSuccess.success).toBe(false); + if (!unverifiedSuccess.success) { + expect(unverifiedSuccess.error.issues.map((issue) => issue.path.join("."))).toContain("verifiedBy"); + } + }); + + test("validates announcements with per-channel delivery status", () => { + const announcement = parseContract(SCHEMA_IDS.announcement, { + schema: SCHEMA_IDS.announcement, + id: "announcement_open_todos_0_11_63", + createdAt, + campaignId: "campaign_open_todos_0_11_63", + appId: "open-todos", + releaseRef: { kind: "release", id: "release_open_todos_0_11_63" }, + channels: [ + { channel: "email", status: "sent", deliveredAt: createdAt }, + { channel: "telegram", status: "failed", detail: "bot token expired" } + ], + audienceRef: { kind: "audience", id: "audience_oss_operators" }, + sentAt: createdAt + }); + expect(announcement.channels).toHaveLength(2); + }); + + test("rejects announcements with wrong ref kinds or incomplete channel states", () => { + const base = { + schema: SCHEMA_IDS.announcement, + id: "announcement_bad", + createdAt, + campaignId: "campaign_bad", + channels: [{ channel: "email", status: "sent", deliveredAt: createdAt }], + audienceRef: { kind: "audience", id: "audience_oss_operators" }, + sentAt: createdAt + } as const; + + expect(validateContract(SCHEMA_IDS.announcement, { ...base, audienceRef: { kind: "task", id: "x" } }).success).toBe(false); + expect(validateContract(SCHEMA_IDS.announcement, { ...base, releaseRef: { kind: "task", id: "x" } }).success).toBe(false); + expect(validateContract(SCHEMA_IDS.announcement, { ...base, channels: [] }).success).toBe(false); + expect( + validateContract(SCHEMA_IDS.announcement, { ...base, channels: [{ channel: "email", status: "sent" }] }).success + ).toBe(false); + expect( + validateContract(SCHEMA_IDS.announcement, { ...base, channels: [{ channel: "telegram", status: "failed" }] }).success + ).toBe(false); + }); + + test("validates audiences with tag/attribute/group predicates and consent policy", () => { + const audience = parseContract(SCHEMA_IDS.audience, { + schema: SCHEMA_IDS.audience, + id: "audience_oss_operators", + createdAt, + audienceId: "oss-operators", + name: "OSS fleet operators", + definition: { + predicates: [ + { kind: "tag", value: "fleet-operator" }, + { kind: "attribute", key: "machine", op: "in", values: ["spark01", "spark02"] }, + { kind: "group", op: "exists", value: "oss" } + ] + }, + consentPolicy: "opt_in", + suppressionSyncedAt: null + }); + expect(audience.definition.match).toBe("all"); + expect(audience.definition.predicates[0]?.op).toBe("eq"); + }); + + test("rejects audiences with malformed predicates", () => { + const base = { + schema: SCHEMA_IDS.audience, + id: "audience_bad", + createdAt, + audienceId: "oss-operators", + name: "OSS fleet operators", + consentPolicy: "opt_in" + } as const; + + const missingKey = validateContract(SCHEMA_IDS.audience, { + ...base, + definition: { predicates: [{ kind: "attribute", op: "eq", value: "spark01" }] } + }); + expect(missingKey.success).toBe(false); + if (!missingKey.success) { + expect(missingKey.error.issues.map((issue) => issue.path.join("."))).toContain("definition.predicates.0.key"); + } + + expect( + validateContract(SCHEMA_IDS.audience, { + ...base, + definition: { predicates: [{ kind: "tag", op: "in", values: [] }] } + }).success + ).toBe(false); + expect(validateContract(SCHEMA_IDS.audience, { ...base, definition: { predicates: [] } }).success).toBe(false); + }); + + test("app cloud manifests reference canonical hasna.app.v1 identity by appId slug", () => { + const manifest = { + schema: SCHEMA_IDS.appCloudManifest, + id: "cloud_manifest_open_todos", + createdAt, + packageName: "@hasna/todos", + appId: "open-todos", + storageMode: "local_only", + cloudBoundary: "none" + } as const; + expect(validateContract(SCHEMA_IDS.appCloudManifest, manifest).success).toBe(true); + expect(validateContract(SCHEMA_IDS.appCloudManifest, { ...manifest, appId: "Open Todos!" }).success).toBe(false); + }); +}); From e4baf61e7825d32ddc6411a5d891f0ab697649dd Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Mon, 6 Jul 2026 05:44:56 -0700 Subject: [PATCH 2/3] feat: API-key auth kit, key issuer, and OpenAPI SDK generator (@hasna/contracts 0.4.1) (#6) * feat(auth): stateless verifiable API-key auth kit HMAC-signed, self-describing keys (prefix hasna__) with a scope grammar (: + wildcards), TTL, and hashed-at-rest records. - keys.ts: mint/parse/verify signed tokens; sha256 at rest, secret shown once - scopes.ts: scope grammar + wildcard matcher - store.ts: DB-backed hashed-record store + revocation list (kit-agnostic client) - middleware.ts: Express/Hono-agnostic verifyApiKey() + per-request audit hook * feat(sdk): typed SDK generator from a serve OpenAPI document generateSdkFromOpenApi() emits a dependency-free fetch client + interfaces from components.schemas; sends the API key as x-api-key for self_hosted use. * feat(cli): issue-key issuer + bootstrap key Mints a scoped API key, persists only the hashed record to the app RDS, and prints the secret once. --bootstrap mints an :* admin key. * chore(release): export auth+sdk, document usage, bump to 0.4.1 Wire ./auth and ./sdk package exports and build entrypoints (pinned bun --root src), clean dist before the release self-scan, and document the exact serve import path + env vars. Bump @hasna/contracts to 0.4.1. --- README.md | 78 ++++++++ package.json | 14 +- src/auth/index.ts | 11 ++ src/auth/keys.ts | 291 ++++++++++++++++++++++++++++ src/auth/middleware.ts | 229 ++++++++++++++++++++++ src/auth/scopes.ts | 80 ++++++++ src/auth/store.ts | 269 ++++++++++++++++++++++++++ src/cli/index.ts | 27 ++- src/cli/issue-key.ts | 193 ++++++++++++++++++ src/index.ts | 2 + src/schemas.ts | 2 +- src/sdk/generate.ts | 346 +++++++++++++++++++++++++++++++++ tests/auth.test.ts | 419 ++++++++++++++++++++++++++++++++++++++++ tests/issue-key.test.ts | 115 +++++++++++ tests/sdk.test.ts | 115 +++++++++++ 15 files changed, 2186 insertions(+), 5 deletions(-) create mode 100644 src/auth/index.ts create mode 100644 src/auth/keys.ts create mode 100644 src/auth/middleware.ts create mode 100644 src/auth/scopes.ts create mode 100644 src/auth/store.ts create mode 100644 src/cli/issue-key.ts create mode 100644 src/sdk/generate.ts create mode 100644 tests/auth.test.ts create mode 100644 tests/issue-key.test.ts create mode 100644 tests/sdk.test.ts diff --git a/README.md b/README.md index 8103f67..0051949 100644 --- a/README.md +++ b/README.md @@ -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 `-serve` HTTP services. A key is an +HMAC-signed compact token with the human prefix `hasna__`; 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 `-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 `. + +**Serve env vars:** + +| Env var | Purpose | +| ------------------------------ | ---------------------------------------------------------- | +| `HASNA__API_SIGNING_KEY` | HMAC signing secret (falls back to `HASNA_API_SIGNING_KEY`) | +| `HASNA__DATABASE_URL` | RDS URL for the `api_keys` store (revocation lookups) | + +**Client env vars (self_hosted mode):** `_API_URL` + `_API_KEY` — never a DSN. + +Scope grammar is `:` with wildcards (`*`, `:*`, `*:`). + +### 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 ':*', 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__API_SIGNING_KEY` (then `HASNA_API_SIGNING_KEY`); +the record store uses `HASNA__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 `-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 `_API_URL` + `_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 diff --git a/package.json b/package.json index 2a24e9e..975883c 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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": [ @@ -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", diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..5f35e74 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,11 @@ +// Public surface of the Hasna API-key auth kit. +// +// Stateless, HMAC-signed, verifiable keys (prefix `hasna__`), a scope +// grammar (`:` + 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"; diff --git a/src/auth/keys.ts b/src/auth/keys.ts new file mode 100644 index 0000000..b0e04ba --- /dev/null +++ b/src/auth/keys.ts @@ -0,0 +1,291 @@ +// Core API-key crypto for Hasna: stateless, verifiable, HMAC-signed tokens. +// +// A key is a compact, self-describing signed token: +// +// hasna__. +// +// = app slug ([a-z][a-z0-9-]*), also embedded in the signed claims +// = base64url(JSON claims) — { v, kid, app, scopes, iat, exp, agent? } +// = base64url(HMAC-SHA256(signingSecret, "hasna__")) +// +// Verification is STATELESS: the server recomputes the HMAC with its signing +// secret and constant-time compares it — no database round-trip is required to +// prove authenticity, TTL, or scopes. Revocation is the only stateful check and +// is layered on top (see store.ts / middleware.ts) keyed by the claims `kid`. +// +// AT REST the issuer stores sha256(token) (never the plaintext) plus metadata, +// so the secret is shown exactly once at issue time and can never be recovered. + +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { isValidScope } from "./scopes.js"; + +/** Token wire-format version. Bump only on a breaking format change. */ +export const API_KEY_TOKEN_VERSION = 1; + +/** Literal token namespace prefix. */ +export const API_KEY_NAMESPACE = "hasna"; + +/** App slug grammar shared by the token prefix and claims. */ +export const APP_SLUG_PATTERN = /^[a-z][a-z0-9-]*$/; + +/** Full-token structural matcher: hasna__.. */ +const TOKEN_PATTERN = /^hasna_([a-z][a-z0-9-]*)_([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/; + +/** Default TTL applied when a caller does not specify one: 90 days. */ +export const DEFAULT_API_KEY_TTL_SECONDS = 90 * 24 * 60 * 60; + +export interface ApiKeyClaims { + /** Token format version. */ + v: number; + /** Key id — stable identifier used for revocation and record lookup. */ + kid: string; + /** App slug the key authenticates against. */ + app: string; + /** Granted scopes (`:` or wildcards). */ + scopes: string[]; + /** Issued-at, epoch seconds. */ + iat: number; + /** Expiry, epoch seconds; `null` means the key never expires. */ + exp: number | null; + /** Optional issued-to agent/subject (informational). */ + agent?: string; +} + +export interface MintApiKeyOptions { + app: string; + scopes: string[]; + /** HMAC signing secret (server-held). Never embedded in the token. */ + signingSecret: string | Buffer; + /** Seconds until expiry. Omit for the default; pass `null` for no expiry. */ + ttlSeconds?: number | null; + /** Optional issued-to agent/subject. */ + agent?: string; + /** Override the generated key id (tests / deterministic reissue). */ + kid?: string; + /** Epoch milliseconds override for deterministic issuance (tests). */ + nowMs?: number; +} + +export interface MintedApiKey { + /** The secret token — returned ONCE, never stored in plaintext. */ + token: string; + /** Key id (also inside the claims). */ + kid: string; + /** Decoded claims. */ + claims: ApiKeyClaims; + /** sha256 hex digest of the full token — this is what to store at rest. */ + tokenHash: string; + /** Human-recognizable prefix: `hasna__`. */ + prefix: string; +} + +function base64urlEncode(input: Buffer | string): string { + return Buffer.from(input).toString("base64url"); +} + +function toBuffer(secret: string | Buffer): Buffer { + return typeof secret === "string" ? Buffer.from(secret, "utf8") : secret; +} + +function hmac(signingSecret: string | Buffer, message: string): Buffer { + return createHmac("sha256", toBuffer(signingSecret)).update(message, "utf8").digest(); +} + +/** sha256 hex of the full token — the value persisted at rest. */ +export function hashToken(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex"); +} + +/** The `hasna__` prefix for an app slug. */ +export function apiKeyPrefix(app: string): string { + return `${API_KEY_NAMESPACE}_${app}_`; +} + +/** Generate a short, url-safe key id (default 16 hex chars = 8 random bytes). */ +export function generateKid(bytes = 8): string { + return randomBytes(bytes).toString("hex"); +} + +/** + * Mint a new API key. Returns the plaintext token (show once) alongside the + * sha256 hash and metadata to persist. The signing secret is NEVER embedded. + */ +export function mintApiKey(options: MintApiKeyOptions): MintedApiKey { + const app = options.app.trim(); + if (!APP_SLUG_PATTERN.test(app)) { + throw new Error(`Invalid app slug '${options.app}'. Expected ${APP_SLUG_PATTERN}.`); + } + if (!Array.isArray(options.scopes) || options.scopes.length === 0) { + throw new Error("At least one scope is required to mint an API key."); + } + for (const scope of options.scopes) { + if (!isValidScope(scope)) { + throw new Error(`Invalid scope '${scope}'. Expected '*' or ':'.`); + } + } + const secret = toBuffer(options.signingSecret); + if (secret.length < 16) { + throw new Error("signingSecret must be at least 16 bytes of entropy."); + } + + const kid = options.kid ?? generateKid(); + if (!/^[A-Za-z0-9_-]+$/.test(kid)) { + throw new Error(`Invalid kid '${kid}'. Expected url-safe characters only.`); + } + + const nowMs = options.nowMs ?? Date.now(); + const iat = Math.floor(nowMs / 1000); + const ttl = options.ttlSeconds === undefined ? DEFAULT_API_KEY_TTL_SECONDS : options.ttlSeconds; + if (ttl !== null && (!Number.isFinite(ttl) || ttl <= 0)) { + throw new Error("ttlSeconds must be a positive number or null (no expiry)."); + } + const exp = ttl === null ? null : iat + Math.floor(ttl); + + const claims: ApiKeyClaims = { + v: API_KEY_TOKEN_VERSION, + kid, + app, + scopes: [...options.scopes], + iat, + exp, + ...(options.agent !== undefined ? { agent: options.agent } : {}), + }; + + const body = base64urlEncode(JSON.stringify(claims)); + const signingInput = `${apiKeyPrefix(app)}${body}`; + const sig = base64urlEncode(hmac(secret, signingInput)); + const token = `${signingInput}.${sig}`; + + return { + token, + kid, + claims, + tokenHash: hashToken(token), + prefix: apiKeyPrefix(app), + }; +} + +export interface ParsedApiKey { + app: string; + body: string; + sig: string; + claims: ApiKeyClaims; +} + +/** Structural parse (no signature check). Returns null when malformed. */ +export function parseApiKey(token: string): ParsedApiKey | null { + if (typeof token !== "string") return null; + const match = TOKEN_PATTERN.exec(token); + if (!match) return null; + const [, app, body, sig] = match; + if (!app || !body || !sig) return null; + let claims: ApiKeyClaims; + try { + claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as ApiKeyClaims; + } catch { + return null; + } + if ( + typeof claims !== "object" || + claims === null || + typeof claims.kid !== "string" || + typeof claims.app !== "string" || + !Array.isArray(claims.scopes) + ) { + return null; + } + return { app, body, sig, claims }; +} + +export type ApiKeyVerifyFailureReason = + | "malformed" + | "unsupported_version" + | "app_mismatch" + | "bad_signature" + | "not_yet_valid" + | "expired" + | "revoked" + | "insufficient_scope"; + +export type ApiKeyVerifyResult = + | { ok: true; claims: ApiKeyClaims; kid: string; app: string } + | { ok: false; reason: ApiKeyVerifyFailureReason; message: string }; + +export interface VerifyApiKeyTokenOptions { + signingSecret: string | Buffer; + /** Restrict verification to a single app slug (recommended per-service). */ + expectedApp?: string; + /** Epoch milliseconds override for deterministic checks (tests). */ + nowMs?: number; + /** Clock-skew leeway in seconds applied to iat/exp. Default 0. */ + leewaySeconds?: number; + /** Concrete `app:action` scopes ALL of which must be granted. */ + requiredScopes?: readonly string[]; +} + +/** + * Fully verify a token's authenticity, TTL, app binding, and (optionally) + * scopes. Stateless — no revocation lookup. Layer revocation on top via the + * store/middleware. Constant-time on the signature comparison. + */ +export function verifyApiKeyToken(token: string, options: VerifyApiKeyTokenOptions): ApiKeyVerifyResult { + const parsed = parseApiKey(token); + if (!parsed) { + return { ok: false, reason: "malformed", message: "Token is malformed." }; + } + const { app, body, sig, claims } = parsed; + + if (claims.v !== API_KEY_TOKEN_VERSION) { + return { ok: false, reason: "unsupported_version", message: `Unsupported token version ${claims.v}.` }; + } + if (claims.app !== app) { + return { ok: false, reason: "app_mismatch", message: "Token prefix app does not match claims." }; + } + if (options.expectedApp !== undefined && app !== options.expectedApp) { + return { ok: false, reason: "app_mismatch", message: `Token is for app '${app}', expected '${options.expectedApp}'.` }; + } + + const expected = hmac(options.signingSecret, `${apiKeyPrefix(app)}${body}`); + let provided: Buffer; + try { + provided = Buffer.from(sig, "base64url"); + } catch { + return { ok: false, reason: "bad_signature", message: "Signature is not valid base64url." }; + } + if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) { + return { ok: false, reason: "bad_signature", message: "Signature verification failed." }; + } + + const now = Math.floor((options.nowMs ?? Date.now()) / 1000); + const leeway = options.leewaySeconds ?? 0; + if (typeof claims.iat === "number" && now + leeway < claims.iat) { + return { ok: false, reason: "not_yet_valid", message: "Token is not yet valid." }; + } + if (claims.exp !== null && typeof claims.exp === "number" && now - leeway >= claims.exp) { + return { ok: false, reason: "expired", message: "Token has expired." }; + } + + if (options.requiredScopes && options.requiredScopes.length > 0) { + // Local import avoided to keep the crypto module leaf; inline the check. + const granted = claims.scopes; + const satisfies = (required: string): boolean => + granted.some((g) => { + if (g === "*") return true; + const gi = g.indexOf(":"); + const ri = required.indexOf(":"); + if (gi < 0 || ri < 0) return false; + const gApp = g.slice(0, gi); + const gAction = g.slice(gi + 1); + const rApp = required.slice(0, ri); + const rAction = required.slice(ri + 1); + return (gApp === "*" || gApp === rApp) && (gAction === "*" || gAction === rAction); + }); + for (const required of options.requiredScopes) { + if (!satisfies(required)) { + return { ok: false, reason: "insufficient_scope", message: `Missing required scope '${required}'.` }; + } + } + } + + return { ok: true, claims, kid: claims.kid, app }; +} diff --git a/src/auth/middleware.ts b/src/auth/middleware.ts new file mode 100644 index 0000000..4195238 --- /dev/null +++ b/src/auth/middleware.ts @@ -0,0 +1,229 @@ +// Express/Hono-agnostic API-key verification middleware for Hasna serve apps. +// +// The core `verifyApiKey()` returns a framework-free `authenticate()` function +// that takes a header source (a `Headers`, a plain object, or a getter) and +// returns an allow/deny decision with an HTTP status. Thin `expressApiKey()` / +// `honoApiKey()` adapters wrap it for the two supported servers. Every decision +// fires the optional audit hook — the per-request auth AUDIT trail. + +import { + verifyApiKeyToken, + type ApiKeyClaims, + type ApiKeyVerifyFailureReason, +} from "./keys.js"; + +/** Header sources the middleware can read tokens from. */ +export type HeaderSource = + | Headers + | Record + | ((name: string) => string | null | undefined); + +function readHeader(source: HeaderSource, name: string): string | null { + const lower = name.toLowerCase(); + if (typeof source === "function") { + return source(name) ?? source(lower) ?? null; + } + if (typeof Headers !== "undefined" && source instanceof Headers) { + return source.get(name); + } + const record = source as Record; + const value = record[name] ?? record[lower] ?? record[name.toUpperCase()]; + if (Array.isArray(value)) return value[0] ?? null; + return value ?? null; +} + +/** Authenticated principal attached to a request on success. */ +export interface ApiKeyPrincipal { + kid: string; + app: string; + scopes: string[]; + agent: string | null; + claims: ApiKeyClaims; +} + +export interface AuthAuditEvent { + outcome: "allow" | "deny"; + app: string; + kid: string | null; + reason: ApiKeyVerifyFailureReason | "missing_token" | null; + scopesRequired: string[]; + method: string | null; + path: string | null; + status: number; + at: string; +} + +export type AuthAuditHook = (event: AuthAuditEvent) => void | Promise; + +export type AuthDecision = + | { ok: true; status: 200; principal: ApiKeyPrincipal } + | { ok: false; status: 401 | 403; reason: ApiKeyVerifyFailureReason | "missing_token"; message: string }; + +export interface ApiKeyAuthContext { + method?: string | null; + path?: string | null; + /** Concrete `app:action` scopes ALL of which must be granted for this call. */ + requiredScopes?: readonly string[]; +} + +export interface VerifyApiKeyOptions { + /** App slug this service authenticates (tokens for other apps are rejected). */ + app: string; + /** HMAC signing secret (server-held). Required — no insecure default. */ + signingSecret: string | Buffer; + /** + * Revocation check: return true to DENY. Typically `store.isRevoked` (explicit + * revocations) or `store.statusChecker()` (strict: unknown or revoked deny). + */ + isRevoked?: (kid: string) => boolean | Promise; + /** Per-request audit hook. Fires on every allow and deny. */ + audit?: AuthAuditHook; + /** Scopes required for every request this middleware guards. */ + requiredScopes?: readonly string[]; + /** Custom header for the raw key. Default `x-api-key`. */ + headerName?: string; + /** Authorization scheme also accepted. Default `Bearer`. */ + scheme?: string; + /** Clock-skew leeway (seconds) for iat/exp. Default 0. */ + leewaySeconds?: number; + /** Epoch-ms clock override (tests). */ + nowMs?: () => number; +} + +/** Extract the raw token from `x-api-key` or `Authorization: `. */ +export function extractToken(source: HeaderSource, headerName = "x-api-key", scheme = "Bearer"): string | null { + const direct = readHeader(source, headerName); + if (direct && direct.trim().length > 0) return direct.trim(); + const authz = readHeader(source, "authorization"); + if (authz) { + const prefix = `${scheme} `; + if (authz.toLowerCase().startsWith(prefix.toLowerCase())) { + const token = authz.slice(prefix.length).trim(); + if (token.length > 0) return token; + } + } + return null; +} + +export interface ApiKeyVerifier { + /** Authenticate a request from its headers. Never throws on auth failure. */ + authenticate(headers: HeaderSource, context?: ApiKeyAuthContext): Promise; + readonly app: string; +} + +/** + * Build the framework-agnostic verifier. This is the primary entry point the + * serve services call; `expressApiKey`/`honoApiKey` are thin wrappers over it. + */ +export function verifyApiKey(options: VerifyApiKeyOptions): ApiKeyVerifier { + if (!options.app) throw new Error("verifyApiKey requires an 'app' slug."); + if (!options.signingSecret) { + throw new Error("verifyApiKey requires a 'signingSecret'. Set it from HASNA__API_SIGNING_KEY."); + } + const headerName = options.headerName ?? "x-api-key"; + const scheme = options.scheme ?? "Bearer"; + const clock = options.nowMs ?? (() => Date.now()); + + async function emit(event: AuthAuditEvent): Promise { + if (!options.audit) return; + try { + await options.audit(event); + } catch { + // Auditing must never break the request path. + } + } + + async function authenticate(headers: HeaderSource, context: ApiKeyAuthContext = {}): Promise { + const method = context.method ?? null; + const path = context.path ?? null; + const requiredScopes = [...(options.requiredScopes ?? []), ...(context.requiredScopes ?? [])]; + const at = new Date(clock()).toISOString(); + + const token = extractToken(headers, headerName, scheme); + if (!token) { + const decision: AuthDecision = { + ok: false, + status: 401, + reason: "missing_token", + message: `Missing API key. Send it as '${headerName}: ' or 'Authorization: ${scheme} '.`, + }; + await emit({ outcome: "deny", app: options.app, kid: null, reason: "missing_token", scopesRequired: requiredScopes, method, path, status: 401, at }); + return decision; + } + + const verified = verifyApiKeyToken(token, { + signingSecret: options.signingSecret, + expectedApp: options.app, + nowMs: clock(), + ...(options.leewaySeconds !== undefined ? { leewaySeconds: options.leewaySeconds } : {}), + requiredScopes, + }); + + if (!verified.ok) { + const status: 401 | 403 = verified.reason === "insufficient_scope" ? 403 : 401; + await emit({ outcome: "deny", app: options.app, kid: null, reason: verified.reason, scopesRequired: requiredScopes, method, path, status, at }); + return { ok: false, status, reason: verified.reason, message: verified.message }; + } + + if (options.isRevoked) { + const revoked = await options.isRevoked(verified.kid); + if (revoked) { + await emit({ outcome: "deny", app: options.app, kid: verified.kid, reason: "revoked", scopesRequired: requiredScopes, method, path, status: 401, at }); + return { ok: false, status: 401, reason: "revoked", message: "API key has been revoked." }; + } + } + + const principal: ApiKeyPrincipal = { + kid: verified.kid, + app: verified.app, + scopes: verified.claims.scopes, + agent: verified.claims.agent ?? null, + claims: verified.claims, + }; + await emit({ outcome: "allow", app: options.app, kid: verified.kid, reason: null, scopesRequired: requiredScopes, method, path, status: 200, at }); + return { ok: true, status: 200, principal }; + } + + return { authenticate, app: options.app }; +} + +// --- Framework adapters (typed loosely to avoid runtime framework deps) --- + +/** + * Express middleware. On success sets `req.apiKey` (the principal) and calls + * `next()`. On failure responds `{ error, reason }` with the right status. + */ +export function expressApiKey(options: VerifyApiKeyOptions) { + const verifier = verifyApiKey(options); + return async (req: any, res: any, next: any): Promise => { + const decision = await verifier.authenticate(req.headers, { + method: req.method, + path: req.originalUrl ?? req.url ?? req.path, + }); + if (decision.ok) { + req.apiKey = decision.principal; + next(); + return; + } + res.status(decision.status).json({ error: decision.message, reason: decision.reason }); + }; +} + +/** + * Hono middleware. On success sets `c.set("apiKey", principal)` and awaits + * `next()`. On failure returns a JSON error with the right status. + */ +export function honoApiKey(options: VerifyApiKeyOptions) { + const verifier = verifyApiKey(options); + return async (c: any, next: any): Promise => { + const decision = await verifier.authenticate((name: string) => c.req.header(name), { + method: c.req.method, + path: c.req.path, + }); + if (decision.ok) { + c.set("apiKey", decision.principal); + return next(); + } + return c.json({ error: decision.message, reason: decision.reason }, decision.status); + }; +} diff --git a/src/auth/scopes.ts b/src/auth/scopes.ts new file mode 100644 index 0000000..0d5fce7 --- /dev/null +++ b/src/auth/scopes.ts @@ -0,0 +1,80 @@ +// Scope grammar for Hasna API keys. +// +// A scope is `:` where each side is either a concrete slug/action +// token or the wildcard `*`. The bare token `*` is a superuser grant that +// matches every scope. Required scopes are always concrete (`app:action`). +// +// Examples of GRANTED scopes: +// "*" -> superuser: satisfies any required scope +// "todos:*" -> every action on the todos app +// "*:read" -> the read action on any app +// "todos:read" -> exactly todos:read +// "todos:tasks.create" -> namespaced action (dotted) +// +// Required scopes must be fully-qualified `app:action` (no wildcards). + +/** A single scope token side: `*` or a slug/action (`[a-z][a-z0-9-.]*`). */ +const SCOPE_PART = /^(?:\*|[a-z][a-z0-9-]*(?:\.[a-z0-9-]+)*)$/; + +/** Grant grammar: `*` OR `:`. */ +export function isValidScope(scope: string): boolean { + if (scope === "*") return true; + const idx = scope.indexOf(":"); + if (idx <= 0 || idx === scope.length - 1) return false; + const app = scope.slice(0, idx); + const action = scope.slice(idx + 1); + return SCOPE_PART.test(app) && SCOPE_PART.test(action); +} + +/** Required scopes must be concrete `app:action` with no wildcards. */ +export function isConcreteScope(scope: string): boolean { + if (!isValidScope(scope) || scope === "*") return false; + return !scope.includes("*"); +} + +/** Split `app:action` -> [app, action]; `*` -> ["*", "*"]. */ +function parts(scope: string): [string, string] { + if (scope === "*") return ["*", "*"]; + const idx = scope.indexOf(":"); + return [scope.slice(0, idx), scope.slice(idx + 1)]; +} + +/** + * Does a single GRANTED scope satisfy a concrete REQUIRED scope? + * Wildcards on the grant side match; the required side must be concrete. + */ +export function scopeMatches(granted: string, required: string): boolean { + if (!isValidScope(granted) || !isConcreteScope(required)) return false; + if (granted === "*") return true; + const [gApp, gAction] = parts(granted); + const [rApp, rAction] = parts(required); + const appOk = gApp === "*" || gApp === rApp; + const actionOk = gAction === "*" || gAction === rAction; + return appOk && actionOk; +} + +/** Does ANY granted scope satisfy the concrete required scope? */ +export function hasScope(granted: readonly string[], required: string): boolean { + return granted.some((g) => scopeMatches(g, required)); +} + +/** Do the granted scopes satisfy EVERY required scope? */ +export function hasAllScopes(granted: readonly string[], required: readonly string[]): boolean { + return required.every((r) => hasScope(granted, r)); +} + +/** Normalize + validate a list of granted scopes; throws on any invalid token. */ +export function normalizeScopes(scopes: readonly string[]): string[] { + const seen = new Set(); + for (const raw of scopes) { + const scope = raw.trim(); + if (!isValidScope(scope)) { + throw new Error(`Invalid scope '${raw}'. Expected '*' or ':' (e.g. 'todos:read', 'todos:*').`); + } + seen.add(scope); + } + if (seen.size === 0) { + throw new Error("At least one scope is required."); + } + return [...seen].sort(); +} diff --git a/src/auth/store.ts b/src/auth/store.ts new file mode 100644 index 0000000..f7d9f63 --- /dev/null +++ b/src/auth/store.ts @@ -0,0 +1,269 @@ +// Persistence for Hasna API keys: hashed records + revocation list. +// +// Stores ONLY the sha256 hash of each token (never the plaintext) plus metadata +// needed for revocation and audit. Built on a minimal structural query-client +// interface so it works with the vendored storage kit's `TypedQueryClient`, a +// raw `pg.Pool` wrapper, or a lightweight test shim — no direct `pg` import. +// +// PURE REMOTE (Amendment A1): every call hits the cloud Postgres directly. There +// is no cache and no local mirror; a revocation check reads the row each time. + +import type { ApiKeyClaims, MintedApiKey } from "./keys.js"; + +/** Minimal row shape. Compatible with `pg` QueryResultRow. */ +export type Row = Record; + +/** + * Structural subset of the storage kit's `TypedQueryClient`. Any object with + * these methods (the kit client, a pool wrapper, or a test shim) works. + */ +export interface AuthQueryClient { + many(sql: string, params?: readonly unknown[]): Promise; + get(sql: string, params?: readonly unknown[]): Promise; + execute(sql: string, params?: readonly unknown[]): Promise; +} + +export const DEFAULT_API_KEYS_TABLE = "api_keys"; + +export interface ApiKeyRecord { + kid: string; + app: string; + agent: string | null; + scopes: string[]; + tokenHash: string; + issuedAt: string; + expiresAt: string | null; + revokedAt: string | null; + revokedReason: string | null; + lastUsedAt: string | null; + createdBy: string | null; +} + +export type ApiKeyStatus = "active" | "revoked" | "expired" | "unknown"; + +/** Migration id + SQL, feedable to the kit's `MigrationLedger`. */ +export interface AuthMigration { + readonly id: string; + readonly sql: string; +} + +function createTableSql(table: string): string { + return `CREATE TABLE IF NOT EXISTS ${table} ( + kid TEXT PRIMARY KEY, + app TEXT NOT NULL, + agent TEXT, + scopes JSONB NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + revoked_reason TEXT, + last_used_at TIMESTAMPTZ, + created_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )`; +} + +/** Ordered migrations for the api-keys table (id namespaced to avoid clashes). */ +export function apiKeyMigrations(table: string = DEFAULT_API_KEYS_TABLE): AuthMigration[] { + return [ + { id: `hasna_auth_0001_${table}`, sql: createTableSql(table) }, + { + id: `hasna_auth_0002_${table}_indexes`, + sql: `CREATE INDEX IF NOT EXISTS ${table}_app_idx ON ${table} (app); + CREATE INDEX IF NOT EXISTS ${table}_token_hash_idx ON ${table} (token_hash);`, + }, + ]; +} + +function toIso(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (value instanceof Date) return value.toISOString(); + return new Date(String(value)).toISOString(); +} + +function parseScopes(value: unknown): string[] { + if (Array.isArray(value)) return value.map((v) => String(v)); + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.map((v) => String(v)) : []; + } catch { + return []; + } + } + return []; +} + +function rowToRecord(row: Row): ApiKeyRecord { + return { + kid: String(row.kid), + app: String(row.app), + agent: row.agent === null || row.agent === undefined ? null : String(row.agent), + scopes: parseScopes(row.scopes), + tokenHash: String(row.token_hash), + issuedAt: toIso(row.issued_at) ?? new Date(0).toISOString(), + expiresAt: toIso(row.expires_at), + revokedAt: toIso(row.revoked_at), + revokedReason: row.revoked_reason === null || row.revoked_reason === undefined ? null : String(row.revoked_reason), + lastUsedAt: toIso(row.last_used_at), + createdBy: row.created_by === null || row.created_by === undefined ? null : String(row.created_by), + }; +} + +export interface InsertKeyInput { + kid: string; + app: string; + agent?: string | null; + scopes: string[]; + tokenHash: string; + issuedAt: Date; + expiresAt: Date | null; + createdBy?: string | null; +} + +export interface ApiKeyStoreOptions { + table?: string; +} + +/** DB-backed store for issued API keys. */ +export class ApiKeyStore { + readonly table: string; + + constructor(private readonly client: AuthQueryClient, options: ApiKeyStoreOptions = {}) { + this.table = options.table ?? DEFAULT_API_KEYS_TABLE; + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(this.table)) { + throw new Error(`Invalid api-keys table name '${this.table}'.`); + } + } + + /** Migrations for this store's table, for the kit's `MigrationLedger`. */ + migrations(): AuthMigration[] { + return apiKeyMigrations(this.table); + } + + /** Idempotently create the table + indexes (standalone path). */ + async ensureSchema(): Promise { + for (const migration of this.migrations()) { + await this.client.execute(migration.sql); + } + } + + /** Insert a hashed key record. Throws on duplicate kid/token hash. */ + async insert(input: InsertKeyInput): Promise { + await this.client.execute( + `INSERT INTO ${this.table} + (kid, app, agent, scopes, token_hash, issued_at, expires_at, created_by) + VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8)`, + [ + input.kid, + input.app, + input.agent ?? null, + JSON.stringify(input.scopes), + input.tokenHash, + input.issuedAt.toISOString(), + input.expiresAt ? input.expiresAt.toISOString() : null, + input.createdBy ?? null, + ], + ); + } + + /** Convenience: persist the record for a freshly minted key. */ + async insertMinted(minted: MintedApiKey, createdBy?: string): Promise { + const claims: ApiKeyClaims = minted.claims; + await this.insert({ + kid: minted.kid, + app: claims.app, + agent: claims.agent ?? null, + scopes: claims.scopes, + tokenHash: minted.tokenHash, + issuedAt: new Date(claims.iat * 1000), + expiresAt: claims.exp === null ? null : new Date(claims.exp * 1000), + createdBy: createdBy ?? null, + }); + } + + async findByKid(kid: string): Promise { + const row = await this.client.get(`SELECT * FROM ${this.table} WHERE kid = $1`, [kid]); + return row ? rowToRecord(row) : null; + } + + async findByTokenHash(tokenHash: string): Promise { + const row = await this.client.get(`SELECT * FROM ${this.table} WHERE token_hash = $1`, [tokenHash]); + return row ? rowToRecord(row) : null; + } + + /** + * Revocation check for the middleware. Returns `true` (deny) only when a + * record exists AND is explicitly revoked. Unknown kids return `false` — the + * token is cryptographically valid and simply was not persisted here. Use + * {@link statusChecker} for strict "must be recorded and active" semantics. + */ + isRevoked = async (kid: string): Promise => { + const row = await this.client.get(`SELECT revoked_at FROM ${this.table} WHERE kid = $1`, [kid]); + if (!row) return false; + return row.revoked_at !== null && row.revoked_at !== undefined; + }; + + /** Resolve the lifecycle status of a kid (unknown/active/revoked/expired). */ + async status(kid: string, nowMs = Date.now()): Promise { + const record = await this.findByKid(kid); + if (!record) return "unknown"; + if (record.revokedAt) return "revoked"; + if (record.expiresAt && new Date(record.expiresAt).getTime() <= nowMs) return "expired"; + return "active"; + } + + /** + * Strict checker for {@link import("./middleware.js")}: denies unknown OR + * revoked kids (an unrecorded token cannot authenticate). + */ + statusChecker(): (kid: string) => Promise { + return async (kid: string): Promise => { + const status = await this.status(kid); + return status !== "active"; + }; + } + + /** Revoke a key by kid. Returns true if a row was affected. */ + async revoke(kid: string, reason?: string, atMs = Date.now()): Promise { + const row = await this.client.get( + `UPDATE ${this.table} + SET revoked_at = COALESCE(revoked_at, $2), revoked_reason = COALESCE(revoked_reason, $3) + WHERE kid = $1 + RETURNING kid`, + [kid, new Date(atMs).toISOString(), reason ?? null], + ); + return row !== null; + } + + /** Record last-used for a kid (best-effort telemetry). */ + async touchLastUsed(kid: string, atMs = Date.now()): Promise { + await this.client.execute(`UPDATE ${this.table} SET last_used_at = $2 WHERE kid = $1`, [ + kid, + new Date(atMs).toISOString(), + ]); + } + + /** List keys, optionally filtered by app / excluding revoked. */ + async list(options: { app?: string; includeRevoked?: boolean } = {}): Promise { + const clauses: string[] = []; + const params: unknown[] = []; + if (options.app) { + params.push(options.app); + clauses.push(`app = $${params.length}`); + } + if (!options.includeRevoked) { + clauses.push("revoked_at IS NULL"); + } + const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = await this.client.many(`SELECT * FROM ${this.table} ${where} ORDER BY issued_at DESC`); + return rows.map(rowToRecord); + } + + /** The set of currently-revoked kids (for building an in-memory deny-set). */ + async revokedKids(): Promise { + const rows = await this.client.many(`SELECT kid FROM ${this.table} WHERE revoked_at IS NOT NULL`); + return rows.map((row) => String(row.kid)); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 212b207..a66a51c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -12,6 +12,7 @@ import { scanNoCloudTarget } from "../no-cloud"; import { runRepoConformance } from "../conformance"; import { getEmbeddedSchemaId, validateContract } from "../validators"; import { runVendorKit } from "./kit-runner"; +import { runIssueKey } from "./issue-key"; function collectJsonFiles(root: string): string[] { const stat = statSync(root); @@ -64,10 +65,16 @@ function preflightJsonUsageErrors(argv: string[]) { return false; } - if (!["schemas", "validate", "conformance", "no-cloud-scan", "repo-conformance", "vendor-kit"].includes(command)) { + if (!["schemas", "validate", "conformance", "no-cloud-scan", "repo-conformance", "vendor-kit", "issue-key"].includes(command)) { return reportParserJsonError("commander.unknownCommand", `unknown command '${command}'`); } + // issue-key has rich value-taking options; let commander parse it (its + // CommanderError is already rendered as JSON by main()). + if (command === "issue-key") { + return false; + } + const allowedOptionsByCommand: Record> = { schemas: new Set(["--json", "-j"]), validate: new Set(["--json", "-j", "--schema"]), @@ -345,6 +352,24 @@ export function createContractsProgram() { } }); + program + .command("issue-key") + .description("Mint an API key (prefix hasna__): stores the hashed record and prints the secret ONCE") + .requiredOption("--app ", "App slug the key authenticates (e.g. todos)") + .option("--agent ", "Issued-to agent/subject (informational)") + .option("--scopes ", "Comma-separated scopes, e.g. 'todos:read,todos:write' or 'todos:*'") + .option("--ttl-days ", "Days until expiry (default 90)") + .option("--no-expiry", "Mint a non-expiring key") + .option("--bootstrap", "Mint a bootstrap admin key (scopes default to ':*', agent 'bootstrap')") + .option("--signing-secret-env ", "Env var holding the HMAC signing secret (default HASNA__API_SIGNING_KEY, then HASNA_API_SIGNING_KEY)") + .option("--database-url-env ", "Env var holding the Postgres URL for the record store (default HASNA__DATABASE_URL)") + .option("--table ", "api-keys table name (default api_keys)") + .option("--no-store", "Do not persist the hashed record (print secret + hash only)") + .option("-j, --json", "Output JSON") + .action(async (options: Record) => { + await runIssueKey(options, { report: reportCliError }); + }); + return program; } diff --git a/src/cli/issue-key.ts b/src/cli/issue-key.ts new file mode 100644 index 0000000..6269d6d --- /dev/null +++ b/src/cli/issue-key.ts @@ -0,0 +1,193 @@ +// `contracts issue-key` implementation. +// +// Mints a Hasna API key, persists ONLY the hashed record to the app's Postgres, +// and prints the plaintext secret exactly once (that is the command's purpose — +// it is a freshly generated secret, not the disclosure of an at-rest credential). + +import { mintApiKey } from "../auth/keys"; +import { ApiKeyStore, type AuthQueryClient } from "../auth/store"; + +export interface IssueKeyDeps { + report: (options: { json?: boolean }, error: string, details?: Record) => void; + env?: NodeJS.ProcessEnv; + now?: () => number; +} + +function envToken(app: string): string { + return app.toUpperCase().replace(/-/g, "_"); +} + +/** Resolve the signing-secret env var name (never the value) for messages. */ +export function signingSecretEnvName(app: string, override?: string): string { + return override ?? `HASNA_${envToken(app)}_API_SIGNING_KEY`; +} + +/** Resolve the database-url env var name for the record store. */ +export function databaseUrlEnvName(app: string, override?: string): string { + return override ?? `HASNA_${envToken(app)}_DATABASE_URL`; +} + +function parseScopesCsv(csv: unknown): string[] { + if (typeof csv !== "string") return []; + return csv + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +async function connectStore(connectionString: string, table: string): Promise<{ store: ApiKeyStore; close: () => Promise }> { + let pgModule: any; + try { + pgModule = await import("pg"); + } catch { + throw new Error("Persisting the key record requires the 'pg' package. Install it, or pass --no-store."); + } + const Pool = pgModule.default?.Pool ?? pgModule.Pool; + const pool = new Pool({ connectionString }); + const client: AuthQueryClient = { + many: async (sql, params) => (await pool.query(sql, params as unknown[])).rows, + get: async (sql, params) => (await pool.query(sql, params as unknown[])).rows[0] ?? null, + execute: async (sql, params) => { + await pool.query(sql, params as unknown[]); + }, + }; + const store = new ApiKeyStore(client, { table }); + return { store, close: () => pool.end() }; +} + +export async function runIssueKey(options: Record, deps: IssueKeyDeps): Promise { + const env = deps.env ?? process.env; + const json = options.json === true; + const app = String(options.app ?? "").trim(); + if (!app) { + deps.report({ json }, "Missing required option --app.", { code: "missing_app" }); + return; + } + + const bootstrap = options.bootstrap === true; + let scopes = parseScopesCsv(options.scopes); + if (scopes.length === 0) { + if (bootstrap) { + scopes = [`${app}:*`]; + } else { + deps.report({ json }, "Missing --scopes. Provide e.g. --scopes 'todos:read,todos:write' or use --bootstrap.", { + code: "missing_scopes", + }); + return; + } + } + + const agent = options.agent !== undefined ? String(options.agent) : bootstrap ? "bootstrap" : undefined; + + // TTL: --no-expiry => null; else --ttl-days (default 90). + let ttlSeconds: number | null; + if (options.expiry === false) { + ttlSeconds = null; + } else { + const days = options.ttlDays !== undefined ? Number(options.ttlDays) : 90; + if (!Number.isFinite(days) || days <= 0) { + deps.report({ json }, "--ttl-days must be a positive number.", { code: "bad_ttl" }); + return; + } + ttlSeconds = Math.floor(days * 24 * 60 * 60); + } + + const secretEnvName = signingSecretEnvName(app, options.signingSecretEnv as string | undefined); + const fallbackName = options.signingSecretEnv ? undefined : "HASNA_API_SIGNING_KEY"; + const signingSecret = env[secretEnvName] ?? (fallbackName ? env[fallbackName] : undefined); + if (!signingSecret) { + const tried = fallbackName ? `${secretEnvName} (or ${fallbackName})` : secretEnvName; + deps.report({ json }, `No signing secret found. Set the ${tried} env var (openssl rand -hex 32).`, { + code: "missing_signing_secret", + signingSecretEnv: secretEnvName, + }); + return; + } + + let minted; + try { + minted = mintApiKey({ + app, + scopes, + signingSecret, + ttlSeconds, + ...(agent !== undefined ? { agent } : {}), + ...(deps.now ? { nowMs: deps.now() } : {}), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + deps.report({ json }, `Could not mint key: ${message}`, { code: "mint_failed" }); + return; + } + + let stored = false; + const table = (options.table as string | undefined) ?? "api_keys"; + if (options.store !== false) { + const dbEnvName = databaseUrlEnvName(app, options.databaseUrlEnv as string | undefined); + const connectionString = env[dbEnvName]; + if (!connectionString) { + deps.report({ json }, `No database URL found. Set ${dbEnvName}, or pass --no-store to skip persistence.`, { + code: "missing_database_url", + databaseUrlEnv: dbEnvName, + }); + return; + } + let handle: { store: ApiKeyStore; close: () => Promise } | undefined; + try { + handle = await connectStore(connectionString, table); + await handle.store.ensureSchema(); + await handle.store.insertMinted(minted, agent ?? "issue-key"); + stored = true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + deps.report({ json }, `Could not persist key record: ${message}`, { code: "store_failed" }); + return; + } finally { + if (handle) { + try { + await handle.close(); + } catch { + // ignore pool close failure + } + } + } + } + + const expiresAt = minted.claims.exp === null ? null : new Date(minted.claims.exp * 1000).toISOString(); + const issuedAt = new Date(minted.claims.iat * 1000).toISOString(); + + if (json) { + console.log( + JSON.stringify( + { + ok: true, + app, + kid: minted.kid, + agent: agent ?? null, + scopes, + issuedAt, + expiresAt, + tokenHash: minted.tokenHash, + stored, + bootstrap, + // The secret token, shown ONCE. Store it now; it cannot be recovered. + token: minted.token, + }, + null, + 2, + ), + ); + return; + } + + console.log(`Issued API key for app '${app}' (kid ${minted.kid})${bootstrap ? " [bootstrap]" : ""}`); + console.log(` scopes: ${scopes.join(", ")}`); + console.log(` agent: ${agent ?? "-"}`); + console.log(` issued: ${issuedAt}`); + console.log(` expires: ${expiresAt ?? "never"}`); + console.log(` record: ${stored ? `stored (${table})` : "not stored (--no-store)"}`); + console.log(` tokenHash: ${minted.tokenHash}`); + console.log(""); + console.log(" API key (shown once — copy it now, it cannot be recovered):"); + console.log(` ${minted.token}`); +} diff --git a/src/index.ts b/src/index.ts index da714c6..02fdc17 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,3 +5,5 @@ export * from "./mode"; export * from "./service-contract"; export * from "./conformance"; export * from "./kit/generate"; +export * from "./auth/index"; +export * from "./sdk/generate"; diff --git a/src/schemas.ts b/src/schemas.ts index 6b9103c..11a21ac 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const CONTRACTS_PACKAGE_NAME = "@hasna/contracts"; -export const CONTRACTS_PACKAGE_VERSION = "0.4.0"; +export const CONTRACTS_PACKAGE_VERSION = "0.4.1"; export const SCHEMA_IDS = { actorRef: "hasna.actor_ref.v1", diff --git a/src/sdk/generate.ts b/src/sdk/generate.ts new file mode 100644 index 0000000..5fd8618 --- /dev/null +++ b/src/sdk/generate.ts @@ -0,0 +1,346 @@ +// SDK-from-OpenAPI generator helper. +// +// Given a serve app's OpenAPI 3 document, emit a typed, dependency-free TypeScript +// client (fetch-based) plus interfaces derived from `components.schemas`. This is +// the source of the per-app SDK: `-serve` publishes its OpenAPI, and this +// helper turns it into the typed exports every consumer imports. +// +// The generated client speaks the Hasna auth convention out of the box: it sends +// the API key as `x-api-key` (configurable) so a self_hosted client only needs +// `_API_URL` + `_API_KEY`. +// +// Design notes: this is a pragmatic generator for the shapes real Hasna serve +// apps emit (health/ready/version + JSON CRUD). Unsupported constructs degrade to +// `unknown` (never a silent wrong type) and are collected in `warnings`. + +export interface OpenApiDocument { + openapi?: string; + info?: { title?: string; version?: string }; + paths?: Record; + components?: { schemas?: Record }; +} + +type PathItem = Record; + +interface Operation { + operationId?: string; + summary?: string; + description?: string; + parameters?: Parameter[]; + requestBody?: RequestBody; + responses?: Record; +} + +interface Parameter { + name: string; + in: "path" | "query" | "header" | "cookie"; + required?: boolean; + schema?: JsonSchema; +} + +interface RequestBody { + required?: boolean; + content?: Record; +} + +interface ResponseObject { + content?: Record; +} + +export interface JsonSchema { + $ref?: string; + type?: string | string[]; + format?: string; + enum?: unknown[]; + items?: JsonSchema; + properties?: Record; + required?: string[]; + additionalProperties?: boolean | JsonSchema; + allOf?: JsonSchema[]; + oneOf?: JsonSchema[]; + anyOf?: JsonSchema[]; + nullable?: boolean; + description?: string; +} + +const HTTP_METHODS = ["get", "put", "post", "delete", "patch", "options", "head"] as const; +type HttpMethod = (typeof HTTP_METHODS)[number]; + +export interface GenerateSdkOptions { + /** Exported client class name. Default derived from `info.title` or `ApiClient`. */ + className?: string; + /** Header used to send the API key. Default `x-api-key`. */ + apiKeyHeader?: string; +} + +export interface GeneratedOperation { + method: HttpMethod; + path: string; + operationId: string; + functionName: string; +} + +export interface GeneratedSdk { + code: string; + operations: GeneratedOperation[]; + warnings: string[]; +} + +function refName(ref: string): string { + const parts = ref.split("/"); + return sanitizeTypeName(parts[parts.length - 1] ?? "Unknown"); +} + +function sanitizeTypeName(name: string): string { + const cleaned = name.replace(/[^A-Za-z0-9_]/g, "_"); + const prefixed = /^[A-Za-z_]/.test(cleaned) ? cleaned : `T_${cleaned}`; + return prefixed.charAt(0).toUpperCase() + prefixed.slice(1); +} + +function camelCase(input: string): string { + const parts = input.split(/[^A-Za-z0-9]+/).filter(Boolean); + if (parts.length === 0) return "op"; + const head = parts[0]!.charAt(0).toLowerCase() + parts[0]!.slice(1); + const rest = parts.slice(1).map((p) => p.charAt(0).toUpperCase() + p.slice(1)); + const name = [head, ...rest].join(""); + return /^[A-Za-z_]/.test(name) ? name : `op${name}`; +} + +class TypeEmitter { + readonly warnings: string[] = []; + + tsType(schema: JsonSchema | undefined): string { + if (!schema) return "unknown"; + if (schema.$ref) return refName(schema.$ref); + if (schema.allOf && schema.allOf.length > 0) { + return schema.allOf.map((s) => this.tsType(s)).join(" & "); + } + if (schema.oneOf && schema.oneOf.length > 0) { + return schema.oneOf.map((s) => this.tsType(s)).join(" | "); + } + if (schema.anyOf && schema.anyOf.length > 0) { + return schema.anyOf.map((s) => this.tsType(s)).join(" | "); + } + if (schema.enum && schema.enum.length > 0) { + return schema.enum.map((v) => JSON.stringify(v)).join(" | "); + } + const type = Array.isArray(schema.type) ? schema.type[0] : schema.type; + let base: string; + switch (type) { + case "string": + base = "string"; + break; + case "integer": + case "number": + base = "number"; + break; + case "boolean": + base = "boolean"; + break; + case "null": + base = "null"; + break; + case "array": + base = `Array<${this.tsType(schema.items)}>`; + break; + case "object": + base = this.objectType(schema); + break; + default: + if (schema.properties) { + base = this.objectType(schema); + } else { + base = "unknown"; + } + } + if (schema.nullable) base = `${base} | null`; + return base; + } + + private objectType(schema: JsonSchema): string { + const props = schema.properties ?? {}; + const required = new Set(schema.required ?? []); + const entries = Object.entries(props).map(([key, value]) => { + const optional = required.has(key) ? "" : "?"; + return `${JSON.stringify(key)}${optional}: ${this.tsType(value)}`; + }); + if (entries.length === 0) { + if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + return `Record`; + } + return "Record"; + } + let body = `{ ${entries.join("; ")} }`; + if (schema.additionalProperties && typeof schema.additionalProperties === "object") { + body = `${body} & Record`; + } + return body; + } + + interfaceFor(name: string, schema: JsonSchema): string { + const typeName = sanitizeTypeName(name); + if (schema.type === "object" || schema.properties) { + return `export interface ${typeName} ${this.objectType(schema)}\n`; + } + return `export type ${typeName} = ${this.tsType(schema)};\n`; + } +} + +function pickResponseSchema(op: Operation): JsonSchema | undefined { + const responses = op.responses ?? {}; + const order = ["200", "201", "202", "2XX", "default"]; + for (const code of order) { + const res = responses[code]; + const schema = res?.content?.["application/json"]?.schema; + if (schema) return schema; + } + for (const code of Object.keys(responses)) { + if (code.startsWith("2")) { + const schema = responses[code]?.content?.["application/json"]?.schema; + if (schema) return schema; + } + } + return undefined; +} + +function requestBodySchema(op: Operation): JsonSchema | undefined { + return op.requestBody?.content?.["application/json"]?.schema; +} + +function isOperation(value: unknown): value is Operation { + return typeof value === "object" && value !== null; +} + +/** Generate a typed fetch client + interfaces from an OpenAPI 3 document. */ +export function generateSdkFromOpenApi(spec: OpenApiDocument, options: GenerateSdkOptions = {}): GeneratedSdk { + if (!spec || typeof spec !== "object") { + throw new Error("generateSdkFromOpenApi requires an OpenAPI document object."); + } + const emitter = new TypeEmitter(); + const apiKeyHeader = options.apiKeyHeader ?? "x-api-key"; + const className = sanitizeTypeName(options.className ?? spec.info?.title ?? "ApiClient"); + + const schemas = spec.components?.schemas ?? {}; + const typeLines: string[] = []; + for (const [name, schema] of Object.entries(schemas)) { + typeLines.push(emitter.interfaceFor(name, schema)); + } + + const operations: GeneratedOperation[] = []; + const methodLines: string[] = []; + const usedNames = new Set(); + + const paths = spec.paths ?? {}; + for (const rawPath of Object.keys(paths).sort()) { + const pathItem = paths[rawPath]; + if (!pathItem || typeof pathItem !== "object") continue; + for (const method of HTTP_METHODS) { + const op = (pathItem as Record)[method]; + if (!isOperation(op)) continue; + + const derived = op.operationId ?? `${method}_${rawPath}`; + let fnName = camelCase(derived); + while (usedNames.has(fnName)) fnName = `${fnName}_`; + usedNames.add(fnName); + + const params = op.parameters ?? []; + const pathParams = params.filter((p) => p.in === "path"); + const queryParams = params.filter((p) => p.in === "query"); + const bodySchema = requestBodySchema(op); + const responseSchema = pickResponseSchema(op); + const returnType = responseSchema ? emitter.tsType(responseSchema) : "void"; + + const args: string[] = []; + for (const p of pathParams) { + args.push(`${camelCase(p.name)}: ${emitter.tsType(p.schema) || "string"}`); + } + if (bodySchema) { + const bodyRequired = op.requestBody?.required !== false; + args.push(`body${bodyRequired ? "" : "?"}: ${emitter.tsType(bodySchema)}`); + } + if (queryParams.length > 0) { + const queryType = queryParams + .map((p) => `${JSON.stringify(p.name)}${p.required ? "" : "?"}: ${emitter.tsType(p.schema) || "string | number | boolean"}`) + .join("; "); + args.push(`query?: { ${queryType} }`); + } + args.push("init?: RequestInit"); + + // Build the runtime path template. + let pathExpr = "`" + rawPath.replace(/\{([^}]+)\}/g, (_m, name: string) => "${encodeURIComponent(String(" + camelCase(name) + "))}") + "`"; + const hasBody = Boolean(bodySchema); + const hasQuery = queryParams.length > 0; + + const doc = op.summary || op.description ? ` /** ${(op.summary ?? op.description ?? "").replace(/\*\//g, "*\\/")} */\n` : ""; + methodLines.push( + `${doc} async ${fnName}(${args.join(", ")}): Promise<${returnType}> {\n` + + ` return this.request(${JSON.stringify(method.toUpperCase())}, ${pathExpr}, {\n` + + ` ${hasBody ? "body," : "body: undefined,"}\n` + + ` ${hasQuery ? "query," : "query: undefined,"}\n` + + ` init,\n` + + ` });\n` + + ` }`, + ); + operations.push({ method, path: rawPath, operationId: derived, functionName: fnName }); + } + } + + const header = `// @generated from OpenAPI by @hasna/contracts SDK generator — DO NOT EDIT.\n` + + `// Source: ${spec.info?.title ?? "service"} ${spec.info?.version ?? ""}\n\n`; + + const runtime = `export interface ${className}Options {\n` + + ` /** Base URL, e.g. process.env.APP_API_URL. */\n` + + ` baseUrl: string;\n` + + ` /** API key, e.g. process.env.APP_API_KEY. Sent as the '${apiKeyHeader}' header. */\n` + + ` apiKey?: string;\n` + + ` /** Custom fetch (defaults to global fetch). */\n` + + ` fetch?: typeof fetch;\n` + + ` /** Extra headers merged into every request. */\n` + + ` headers?: Record;\n` + + `}\n\n` + + `export class ApiError extends Error {\n` + + ` constructor(readonly status: number, message: string, readonly body: unknown) {\n` + + ` super(message);\n` + + ` this.name = "ApiError";\n` + + ` }\n` + + `}\n\n` + + `export class ${className} {\n` + + ` private readonly baseUrl: string;\n` + + ` private readonly apiKey: string | undefined;\n` + + ` private readonly fetchImpl: typeof fetch;\n` + + ` private readonly baseHeaders: Record;\n\n` + + ` constructor(options: ${className}Options) {\n` + + ` if (!options.baseUrl) throw new Error("${className} requires a baseUrl.");\n` + + ` this.baseUrl = options.baseUrl.replace(/\\/$/, "");\n` + + ` this.apiKey = options.apiKey;\n` + + ` this.fetchImpl = options.fetch ?? globalThis.fetch;\n` + + ` this.baseHeaders = options.headers ?? {};\n` + + ` }\n\n` + + ` private async request(method: string, path: string, opts: { body?: unknown; query?: Record; init?: RequestInit }): Promise {\n` + + ` const url = new URL(this.baseUrl + path);\n` + + ` if (opts.query) {\n` + + ` for (const [key, value] of Object.entries(opts.query)) {\n` + + ` if (value !== undefined && value !== null) url.searchParams.set(key, String(value));\n` + + ` }\n` + + ` }\n` + + ` const headers: Record = { Accept: "application/json", ...this.baseHeaders, ...(opts.init?.headers as Record | undefined) };\n` + + ` if (this.apiKey) headers[${JSON.stringify(apiKeyHeader)}] = this.apiKey;\n` + + ` let payload: BodyInit | undefined;\n` + + ` if (opts.body !== undefined) {\n` + + ` headers["Content-Type"] = "application/json";\n` + + ` payload = JSON.stringify(opts.body);\n` + + ` }\n` + + ` const response = await this.fetchImpl(url.toString(), { ...opts.init, method, headers, body: payload });\n` + + ` const text = await response.text();\n` + + ` const data = text ? (() => { try { return JSON.parse(text); } catch { return text; } })() : undefined;\n` + + ` if (!response.ok) {\n` + + ` throw new ApiError(response.status, \`\${method} \${path} failed: \${response.status}\`, data);\n` + + ` }\n` + + ` return data as T;\n` + + ` }\n\n` + + methodLines.join("\n\n") + + `\n}\n`; + + const code = header + (typeLines.length > 0 ? typeLines.join("\n") + "\n" : "") + runtime; + return { code, operations, warnings: emitter.warnings }; +} diff --git a/tests/auth.test.ts b/tests/auth.test.ts new file mode 100644 index 0000000..e3187db --- /dev/null +++ b/tests/auth.test.ts @@ -0,0 +1,419 @@ +import { describe, expect, test } from "bun:test"; +import { + isValidScope, + isConcreteScope, + scopeMatches, + hasScope, + hasAllScopes, + normalizeScopes, +} from "../src/auth/scopes"; +import { + mintApiKey, + parseApiKey, + verifyApiKeyToken, + hashToken, + apiKeyPrefix, + API_KEY_TOKEN_VERSION, + DEFAULT_API_KEY_TTL_SECONDS, +} from "../src/auth/keys"; +import { + ApiKeyStore, + apiKeyMigrations, + type AuthQueryClient, + type Row, +} from "../src/auth/store"; +import { verifyApiKey, expressApiKey, honoApiKey, extractToken, type AuthAuditEvent } from "../src/auth/middleware"; + +const SIGNING = "test-signing-secret-not-a-real-credential-000"; + +// --- scopes --- +describe("scope grammar", () => { + test("validates grants and concrete requireds", () => { + expect(isValidScope("*")).toBe(true); + expect(isValidScope("todos:*")).toBe(true); + expect(isValidScope("*:read")).toBe(true); + expect(isValidScope("todos:tasks.create")).toBe(true); + expect(isValidScope("todos")).toBe(false); + expect(isValidScope("Todos:Read")).toBe(false); + expect(isValidScope("todos:")).toBe(false); + expect(isConcreteScope("todos:read")).toBe(true); + expect(isConcreteScope("todos:*")).toBe(false); + expect(isConcreteScope("*")).toBe(false); + }); + + test("wildcard matching", () => { + expect(scopeMatches("*", "todos:read")).toBe(true); + expect(scopeMatches("todos:*", "todos:write")).toBe(true); + expect(scopeMatches("*:read", "todos:read")).toBe(true); + expect(scopeMatches("todos:read", "todos:read")).toBe(true); + expect(scopeMatches("todos:read", "todos:write")).toBe(false); + expect(scopeMatches("todos:*", "mementos:read")).toBe(false); + // required must be concrete + expect(scopeMatches("*", "todos:*")).toBe(false); + }); + + test("hasScope / hasAllScopes", () => { + expect(hasScope(["todos:*"], "todos:read")).toBe(true); + expect(hasScope(["mementos:read"], "todos:read")).toBe(false); + expect(hasAllScopes(["todos:*"], ["todos:read", "todos:write"])).toBe(true); + expect(hasAllScopes(["todos:read"], ["todos:read", "todos:write"])).toBe(false); + }); + + test("normalizeScopes dedupes, sorts, and rejects invalid", () => { + expect(normalizeScopes(["todos:write", "todos:read", "todos:read"])).toEqual(["todos:read", "todos:write"]); + expect(() => normalizeScopes(["bad scope"])).toThrow(); + expect(() => normalizeScopes([])).toThrow(); + }); +}); + +// --- keys --- +describe("api key mint + verify", () => { + test("mints a well-formed token with the hasna__ prefix", () => { + const minted = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING }); + expect(minted.token.startsWith(apiKeyPrefix("todos"))).toBe(true); + expect(minted.prefix).toBe("hasna_todos_"); + expect(minted.tokenHash).toBe(hashToken(minted.token)); + expect(minted.claims.v).toBe(API_KEY_TOKEN_VERSION); + expect(minted.claims.app).toBe("todos"); + const parsed = parseApiKey(minted.token); + expect(parsed?.claims.kid).toBe(minted.kid); + }); + + test("default TTL is applied; explicit null means no expiry", () => { + const nowMs = 1_700_000_000_000; + const withTtl = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING, nowMs }); + expect(withTtl.claims.exp).toBe(Math.floor(nowMs / 1000) + DEFAULT_API_KEY_TTL_SECONDS); + const noExp = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING, ttlSeconds: null }); + expect(noExp.claims.exp).toBeNull(); + }); + + test("verifies a good token and binds the app", () => { + const minted = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING }); + const result = verifyApiKeyToken(minted.token, { signingSecret: SIGNING, expectedApp: "todos" }); + expect(result.ok).toBe(true); + const wrongApp = verifyApiKeyToken(minted.token, { signingSecret: SIGNING, expectedApp: "mementos" }); + expect(wrongApp.ok).toBe(false); + if (!wrongApp.ok) expect(wrongApp.reason).toBe("app_mismatch"); + }); + + test("rejects a tampered signature and a wrong signing secret", () => { + const minted = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING }); + const tampered = minted.token.slice(0, -2) + (minted.token.endsWith("aa") ? "bb" : "aa"); + const r1 = verifyApiKeyToken(tampered, { signingSecret: SIGNING }); + expect(r1.ok).toBe(false); + const r2 = verifyApiKeyToken(minted.token, { signingSecret: "a-different-secret-16bytes+more" }); + expect(r2.ok).toBe(false); + if (!r2.ok) expect(r2.reason).toBe("bad_signature"); + }); + + test("rejects a payload with swapped claims (signature covers the body)", () => { + const minted = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING }); + const forgedClaims = { ...minted.claims, scopes: ["todos:*"] }; + const forgedBody = Buffer.from(JSON.stringify(forgedClaims)).toString("base64url"); + const sig = minted.token.split(".")[1]; + const forged = `${apiKeyPrefix("todos")}${forgedBody}.${sig}`; + const r = verifyApiKeyToken(forged, { signingSecret: SIGNING }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("bad_signature"); + }); + + test("enforces TTL", () => { + const nowMs = 1_700_000_000_000; + const minted = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING, ttlSeconds: 60, nowMs }); + const still = verifyApiKeyToken(minted.token, { signingSecret: SIGNING, nowMs: nowMs + 30_000 }); + expect(still.ok).toBe(true); + const expired = verifyApiKeyToken(minted.token, { signingSecret: SIGNING, nowMs: nowMs + 120_000 }); + expect(expired.ok).toBe(false); + if (!expired.ok) expect(expired.reason).toBe("expired"); + }); + + test("enforces required scopes", () => { + const minted = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING }); + const ok = verifyApiKeyToken(minted.token, { signingSecret: SIGNING, requiredScopes: ["todos:read"] }); + expect(ok.ok).toBe(true); + const denied = verifyApiKeyToken(minted.token, { signingSecret: SIGNING, requiredScopes: ["todos:write"] }); + expect(denied.ok).toBe(false); + if (!denied.ok) expect(denied.reason).toBe("insufficient_scope"); + }); + + test("malformed tokens are rejected, not thrown", () => { + expect(verifyApiKeyToken("nonsense", { signingSecret: SIGNING }).ok).toBe(false); + expect(parseApiKey("nope")).toBeNull(); + }); + + test("mint rejects invalid input", () => { + expect(() => mintApiKey({ app: "Bad App", scopes: ["todos:read"], signingSecret: SIGNING })).toThrow(); + expect(() => mintApiKey({ app: "todos", scopes: [], signingSecret: SIGNING })).toThrow(); + expect(() => mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: "short" })).toThrow(); + }); +}); + +// --- in-memory store fake (interprets the store's SQL) --- +class FakeStoreClient implements AuthQueryClient { + rows = new Map(); + + async execute(sql: string, params: readonly unknown[] = []): Promise { + if (sql.includes("CREATE TABLE") || sql.includes("CREATE INDEX")) return; + if (sql.startsWith("INSERT INTO")) { + const [kid, app, agent, scopes, token_hash, issued_at, expires_at, created_by] = params as unknown[]; + if (this.rows.has(String(kid))) throw new Error("duplicate key value violates unique constraint (kid)"); + for (const row of this.rows.values()) { + if (row.token_hash === token_hash) throw new Error("duplicate key value violates unique constraint (token_hash)"); + } + this.rows.set(String(kid), { + kid, + app, + agent, + scopes, + token_hash, + issued_at, + expires_at, + revoked_at: null, + revoked_reason: null, + last_used_at: null, + created_by, + }); + return; + } + if (sql.includes("SET last_used_at")) { + const [kid, at] = params as unknown[]; + const row = this.rows.get(String(kid)); + if (row) row.last_used_at = at; + return; + } + } + + async get(sql: string, params: readonly unknown[] = []): Promise { + if (sql.startsWith("SELECT revoked_at")) { + const row = this.rows.get(String(params[0])); + return row ? ({ revoked_at: row.revoked_at } as unknown as T) : null; + } + if (sql.startsWith("UPDATE") && sql.includes("RETURNING")) { + const [kid, at, reason] = params as unknown[]; + const row = this.rows.get(String(kid)); + if (!row) return null; + if (row.revoked_at === null || row.revoked_at === undefined) { + row.revoked_at = at; + row.revoked_reason = reason ?? null; + } + return { kid: row.kid } as unknown as T; + } + if (sql.includes("token_hash =")) { + for (const row of this.rows.values()) { + if (row.token_hash === params[0]) return row as unknown as T; + } + return null; + } + if (sql.includes("WHERE kid =")) { + const row = this.rows.get(String(params[0])); + return (row as unknown as T) ?? null; + } + return null; + } + + async many(sql: string, _params: readonly unknown[] = []): Promise { + if (sql.includes("revoked_at IS NOT NULL") && sql.includes("SELECT kid")) { + return [...this.rows.values()].filter((r) => r.revoked_at).map((r) => ({ kid: r.kid })) as unknown as T[]; + } + let rows = [...this.rows.values()]; + if (sql.includes("revoked_at IS NULL")) rows = rows.filter((r) => !r.revoked_at); + return rows as unknown as T[]; + } +} + +describe("api key store (fake client)", () => { + test("migration ids are namespaced and deterministic", () => { + const m = apiKeyMigrations("api_keys"); + expect(m[0]?.id).toBe("hasna_auth_0001_api_keys"); + expect(m.length).toBe(2); + }); + + test("insert, find, status, revoke lifecycle", async () => { + const store = new ApiKeyStore(new FakeStoreClient()); + await store.ensureSchema(); + const minted = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING, agent: "alice" }); + await store.insertMinted(minted, "issuer"); + + const byKid = await store.findByKid(minted.kid); + expect(byKid?.app).toBe("todos"); + expect(byKid?.scopes).toEqual(["todos:read"]); + expect(byKid?.agent).toBe("alice"); + + const byHash = await store.findByTokenHash(minted.tokenHash); + expect(byHash?.kid).toBe(minted.kid); + + expect(await store.status(minted.kid)).toBe("active"); + expect(await store.isRevoked(minted.kid)).toBe(false); + + expect(await store.revoke(minted.kid, "compromised")).toBe(true); + expect(await store.isRevoked(minted.kid)).toBe(true); + expect(await store.status(minted.kid)).toBe("revoked"); + expect(await store.revokedKids()).toEqual([minted.kid]); + }); + + test("duplicate insert throws (no silent overwrite)", async () => { + const store = new ApiKeyStore(new FakeStoreClient()); + const minted = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING, kid: "fixedkid" }); + await store.insertMinted(minted); + await expect(store.insertMinted(minted)).rejects.toThrow(); + }); + + test("unknown kid: isRevoked false, status unknown, strict checker denies", async () => { + const store = new ApiKeyStore(new FakeStoreClient()); + expect(await store.isRevoked("ghost")).toBe(false); + expect(await store.status("ghost")).toBe("unknown"); + const strict = store.statusChecker(); + expect(await strict("ghost")).toBe(true); + }); + + test("expired record reports expired status", async () => { + const store = new ApiKeyStore(new FakeStoreClient()); + const nowMs = 1_700_000_000_000; + const minted = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING, ttlSeconds: 60, nowMs }); + await store.insertMinted(minted); + expect(await store.status(minted.kid, nowMs + 120_000)).toBe("expired"); + }); +}); + +// --- middleware --- +describe("verifyApiKey middleware (agnostic)", () => { + test("extractToken from x-api-key and Authorization: Bearer", () => { + expect(extractToken({ "x-api-key": "abc" })).toBe("abc"); + expect(extractToken({ authorization: "Bearer xyz" })).toBe("xyz"); + expect(extractToken({ authorization: "bearer xyz" })).toBe("xyz"); + expect(extractToken({})).toBeNull(); + }); + + test("allows a valid key and denies missing/invalid", async () => { + const events: AuthAuditEvent[] = []; + const verifier = verifyApiKey({ app: "todos", signingSecret: SIGNING, audit: (e) => void events.push(e) }); + const minted = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING }); + + const ok = await verifier.authenticate({ "x-api-key": minted.token }, { method: "GET", path: "/tasks" }); + expect(ok.ok).toBe(true); + if (ok.ok) expect(ok.principal.kid).toBe(minted.kid); + + const missing = await verifier.authenticate({}); + expect(missing.ok).toBe(false); + if (!missing.ok) expect(missing.status).toBe(401); + + expect(events.some((e) => e.outcome === "allow")).toBe(true); + expect(events.some((e) => e.outcome === "deny" && e.reason === "missing_token")).toBe(true); + }); + + test("scope enforcement returns 403", async () => { + const verifier = verifyApiKey({ app: "todos", signingSecret: SIGNING, requiredScopes: ["todos:write"] }); + const minted = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING }); + const decision = await verifier.authenticate({ "x-api-key": minted.token }); + expect(decision.ok).toBe(false); + if (!decision.ok) { + expect(decision.status).toBe(403); + expect(decision.reason).toBe("insufficient_scope"); + } + }); + + test("revocation via store denies the key", async () => { + const store = new ApiKeyStore(new FakeStoreClient()); + const minted = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING }); + await store.insertMinted(minted); + const verifier = verifyApiKey({ app: "todos", signingSecret: SIGNING, isRevoked: store.isRevoked }); + + expect((await verifier.authenticate({ "x-api-key": minted.token })).ok).toBe(true); + await store.revoke(minted.kid); + const after = await verifier.authenticate({ "x-api-key": minted.token }); + expect(after.ok).toBe(false); + if (!after.ok) expect(after.reason).toBe("revoked"); + }); + + test("expired token denied via middleware clock override", async () => { + const nowMs = 1_700_000_000_000; + const minted = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING, ttlSeconds: 60, nowMs }); + const verifier = verifyApiKey({ app: "todos", signingSecret: SIGNING, nowMs: () => nowMs + 120_000 }); + const decision = await verifier.authenticate({ "x-api-key": minted.token }); + expect(decision.ok).toBe(false); + if (!decision.ok) expect(decision.reason).toBe("expired"); + }); + + test("missing signingSecret throws at construction (no insecure default)", () => { + expect(() => verifyApiKey({ app: "todos", signingSecret: "" })).toThrow(); + }); + + test("express adapter sets req.apiKey and rejects", async () => { + const mw = expressApiKey({ app: "todos", signingSecret: SIGNING }); + const minted = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING }); + + const req: any = { headers: { "x-api-key": minted.token }, method: "GET", url: "/x" }; + let nexted = false; + await mw(req, {} as any, () => { + nexted = true; + }); + expect(nexted).toBe(true); + expect(req.apiKey.kid).toBe(minted.kid); + + let status = 0; + let body: any; + const res: any = { status: (s: number) => ({ json: (b: any) => { status = s; body = b; } }) }; + await mw({ headers: {}, method: "GET", url: "/x" } as any, res, () => {}); + expect(status).toBe(401); + expect(body.reason).toBe("missing_token"); + }); + + test("hono adapter sets context and rejects", async () => { + const mw = honoApiKey({ app: "todos", signingSecret: SIGNING }); + const minted = mintApiKey({ app: "todos", scopes: ["todos:read"], signingSecret: SIGNING }); + + let stored: any; + let nexted = false; + const c: any = { + req: { header: (n: string) => (n.toLowerCase() === "x-api-key" ? minted.token : undefined), method: "GET", path: "/x" }, + set: (_k: string, v: any) => { stored = v; }, + json: (b: any, s: number) => ({ b, s }), + }; + await mw(c, async () => { nexted = true; }); + expect(nexted).toBe(true); + expect(stored.kid).toBe(minted.kid); + + const cFail: any = { + req: { header: () => undefined, method: "GET", path: "/x" }, + set: () => {}, + json: (b: any, s: number) => ({ b, s }), + }; + const out: any = await mw(cFail, async () => {}); + expect(out.s).toBe(401); + }); +}); + +// --- gated live Postgres store test --- +const DATABASE_URL = process.env.AUTH_TEST_DATABASE_URL ?? process.env.KIT_TEST_DATABASE_URL; +const describeLive = DATABASE_URL ? describe : describe.skip; + +describeLive("api key store live Postgres", () => { + test("full lifecycle against a real database", async () => { + const pgModule: any = await import("pg"); + const Pool = pgModule.default?.Pool ?? pgModule.Pool; + const pool = new Pool({ connectionString: DATABASE_URL }); + const table = "auth_test_api_keys"; + const client: AuthQueryClient = { + many: async (sql, params) => (await pool.query(sql, params as unknown[])).rows, + get: async (sql, params) => (await pool.query(sql, params as unknown[])).rows[0] ?? null, + execute: async (sql, params) => { await pool.query(sql, params as unknown[]); }, + }; + try { + await pool.query(`DROP TABLE IF EXISTS ${table}`); + const store = new ApiKeyStore(client, { table }); + await store.ensureSchema(); + const minted = mintApiKey({ app: "todos", scopes: ["todos:read", "todos:write"], signingSecret: SIGNING, agent: "live" }); + await store.insertMinted(minted, "livetest"); + const found = await store.findByKid(minted.kid); + expect(found?.scopes.sort()).toEqual(["todos:read", "todos:write"]); + expect(await store.isRevoked(minted.kid)).toBe(false); + expect(await store.revoke(minted.kid, "done")).toBe(true); + expect(await store.isRevoked(minted.kid)).toBe(true); + const list = await store.list({ app: "todos", includeRevoked: true }); + expect(list.some((r) => r.kid === minted.kid)).toBe(true); + } finally { + await pool.query(`DROP TABLE IF EXISTS ${table}`).catch(() => {}); + await pool.end(); + } + }); +}); diff --git a/tests/issue-key.test.ts b/tests/issue-key.test.ts new file mode 100644 index 0000000..7c8afb0 --- /dev/null +++ b/tests/issue-key.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import { runIssueKey, signingSecretEnvName, databaseUrlEnvName } from "../src/cli/issue-key"; +import { verifyApiKeyToken } from "../src/auth/keys"; + +const SIGNING = "test-signing-secret-not-a-real-credential-000"; + +function collectReports() { + const reports: Array<{ error: string; details?: Record }> = []; + return { + reports, + report: (_o: { json?: boolean }, error: string, details?: Record) => { + reports.push({ error, ...(details ? { details } : {}) }); + }, + }; +} + +async function captureStdout(fn: () => Promise): Promise { + const original = console.log; + const lines: string[] = []; + console.log = (...args: unknown[]) => void lines.push(args.map((a) => String(a)).join(" ")); + try { + await fn(); + } finally { + console.log = original; + } + return lines.join("\n"); +} + +describe("issue-key env name resolution", () => { + test("default env names follow the HASNA__ convention", () => { + expect(signingSecretEnvName("todos")).toBe("HASNA_TODOS_API_SIGNING_KEY"); + expect(signingSecretEnvName("open-brain")).toBe("HASNA_OPEN_BRAIN_API_SIGNING_KEY"); + expect(databaseUrlEnvName("todos")).toBe("HASNA_TODOS_DATABASE_URL"); + expect(signingSecretEnvName("todos", "CUSTOM_ENV")).toBe("CUSTOM_ENV"); + }); +}); + +describe("runIssueKey", () => { + test("errors when --app is missing", async () => { + const { reports, report } = collectReports(); + await runIssueKey({ json: true }, { report, env: {} }); + expect(reports[0]?.error).toContain("--app"); + }); + + test("errors when scopes missing and not bootstrap", async () => { + const { reports, report } = collectReports(); + await runIssueKey({ app: "todos", json: true }, { report, env: { HASNA_TODOS_API_SIGNING_KEY: SIGNING } }); + expect(reports[0]?.error).toContain("--scopes"); + }); + + test("errors when signing secret missing", async () => { + const { reports, report } = collectReports(); + await runIssueKey({ app: "todos", scopes: "todos:read", store: false, json: true }, { report, env: {} }); + expect(reports[0]?.error).toContain("signing secret"); + }); + + test("mints and prints a JSON key (no-store) that verifies", async () => { + const { reports, report } = collectReports(); + let out = ""; + out = await captureStdout(async () => { + await runIssueKey( + { app: "todos", scopes: "todos:read,todos:write", store: false, json: true, agent: "ci" }, + { report, env: { HASNA_TODOS_API_SIGNING_KEY: SIGNING } }, + ); + }); + expect(reports).toEqual([]); + const parsed = JSON.parse(out); + expect(parsed.ok).toBe(true); + expect(parsed.stored).toBe(false); + expect(parsed.app).toBe("todos"); + expect(parsed.scopes).toEqual(["todos:read", "todos:write"]); + expect(parsed.token.startsWith("hasna_todos_")).toBe(true); + const verified = verifyApiKeyToken(parsed.token, { signingSecret: SIGNING, expectedApp: "todos", requiredScopes: ["todos:write"] }); + expect(verified.ok).toBe(true); + }); + + test("bootstrap defaults scopes to :* and agent bootstrap", async () => { + const { reports, report } = collectReports(); + const out = await captureStdout(async () => { + await runIssueKey( + { app: "todos", bootstrap: true, store: false, json: true }, + { report, env: { HASNA_API_SIGNING_KEY: SIGNING } }, + ); + }); + expect(reports).toEqual([]); + const parsed = JSON.parse(out); + expect(parsed.scopes).toEqual(["todos:*"]); + expect(parsed.agent).toBe("bootstrap"); + expect(parsed.bootstrap).toBe(true); + }); + + test("falls back to HASNA_API_SIGNING_KEY when app-specific is absent", async () => { + const { reports, report } = collectReports(); + const out = await captureStdout(async () => { + await runIssueKey( + { app: "todos", scopes: "todos:read", store: false, json: true }, + { report, env: { HASNA_API_SIGNING_KEY: SIGNING } }, + ); + }); + expect(reports).toEqual([]); + expect(JSON.parse(out).ok).toBe(true); + }); + + test("no-expiry mints a non-expiring key", async () => { + const { reports, report } = collectReports(); + const out = await captureStdout(async () => { + await runIssueKey( + { app: "todos", scopes: "todos:read", store: false, json: true, expiry: false }, + { report, env: { HASNA_TODOS_API_SIGNING_KEY: SIGNING } }, + ); + }); + expect(reports).toEqual([]); + expect(JSON.parse(out).expiresAt).toBeNull(); + }); +}); diff --git a/tests/sdk.test.ts b/tests/sdk.test.ts new file mode 100644 index 0000000..8284368 --- /dev/null +++ b/tests/sdk.test.ts @@ -0,0 +1,115 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { generateSdkFromOpenApi, type OpenApiDocument } from "../src/sdk/generate"; + +const spec: OpenApiDocument = { + openapi: "3.0.3", + info: { title: "Todos Serve", version: "1.0.0" }, + components: { + schemas: { + Task: { + type: "object", + required: ["id", "title"], + properties: { + id: { type: "string" }, + title: { type: "string" }, + done: { type: "boolean" }, + priority: { type: "string", enum: ["low", "high"] }, + }, + }, + HealthStatus: { + type: "object", + required: ["status", "version", "mode"], + properties: { + status: { type: "string" }, + version: { type: "string" }, + mode: { type: "string" }, + }, + }, + }, + }, + paths: { + "/health": { + get: { operationId: "getHealth", summary: "Health probe", responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/HealthStatus" } } } } } }, + }, + "/tasks": { + get: { + operationId: "listTasks", + parameters: [{ name: "limit", in: "query", required: false, schema: { type: "integer" } }], + responses: { "200": { content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Task" } } } } } }, + }, + post: { + operationId: "createTask", + requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/Task" } } } }, + responses: { "201": { content: { "application/json": { schema: { $ref: "#/components/schemas/Task" } } } } }, + }, + }, + "/tasks/{id}": { + get: { + operationId: "getTask", + parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], + responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Task" } } } } }, + }, + }, + }, +}; + +const tmp = mkdtempSync(join(tmpdir(), "hasna-sdk-")); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +describe("SDK from OpenAPI generator", () => { + test("emits interfaces, a client class, and one method per operation", () => { + const sdk = generateSdkFromOpenApi(spec, { className: "TodosClient" }); + expect(sdk.code).toContain("export interface Task"); + expect(sdk.code).toContain("export interface HealthStatus"); + expect(sdk.code).toContain("export class TodosClient"); + expect(sdk.code).toContain('"low" | "high"'); + expect(sdk.operations.map((o) => o.functionName).sort()).toEqual(["createTask", "getHealth", "getTask", "listTasks"]); + expect(sdk.warnings).toEqual([]); + }); + + test("generated client is valid and drives requests with a mocked fetch", async () => { + const sdk = generateSdkFromOpenApi(spec, { className: "TodosClient" }); + const file = join(tmp, "client.ts"); + writeFileSync(file, sdk.code, "utf8"); + const mod: any = await import(file); + expect(typeof mod.TodosClient).toBe("function"); + + const calls: Array<{ url: string; method: string; headers: Record; body: unknown }> = []; + const fakeFetch = (async (url: string, init: any) => { + calls.push({ url: String(url), method: init.method, headers: init.headers, body: init.body ? JSON.parse(init.body) : undefined }); + if (String(url).includes("/health")) { + return new Response(JSON.stringify({ status: "ok", version: "1.0.0", mode: "self_hosted" }), { status: 200 }); + } + return new Response(JSON.stringify({ id: "t1", title: "hello" }), { status: 200 }); + }) as unknown as typeof fetch; + + const client = new mod.TodosClient({ baseUrl: "https://todos.example.com/", apiKey: "hasna_todos_KEY", fetch: fakeFetch }); + + const health = await client.getHealth(); + expect(health.status).toBe("ok"); + + const task = await client.getTask("t 1"); + expect(task.id).toBe("t1"); + + await client.listTasks({ limit: 5 }); + await client.createTask({ id: "t2", title: "new" }); + + // API key header sent on every call. + expect(calls.every((c) => c.headers["x-api-key"] === "hasna_todos_KEY")).toBe(true); + // Path templating with encoding. + expect(calls.find((c) => c.url.includes("/tasks/"))!.url).toContain("t%201"); + // Query serialization. + expect(calls.find((c) => c.url.includes("limit="))!.url).toContain("limit=5"); + // POST body. + const post = calls.find((c) => c.method === "POST")!; + expect(post.body).toEqual({ id: "t2", title: "new" }); + }); + + test("throws on a non-object spec (no silent stub)", () => { + // @ts-expect-error intentional bad input + expect(() => generateSdkFromOpenApi(null)).toThrow(); + }); +}); From 4ad7447a5b7ef0bfa23ea902597935e03e14031d Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Mon, 6 Jul 2026 16:08:46 +0300 Subject: [PATCH 3/3] fix: resolve distribution schema merge blockers --- README.md | 8 ++++--- examples/rollout-record.invalid.json | 8 +++---- src/no-cloud.ts | 8 ++++++- src/schemas.ts | 30 +++++++++++++++++------ tests/cli.test.ts | 27 +++++++++++++++++++++ tests/examples.test.ts | 2 +- tests/schemas.test.ts | 36 ++++++++++++++++++++++++++-- 7 files changed, 100 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 4dc124a..3157f94 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,8 @@ defaults are applied; output aliases such as `EvidenceRef` describe parsed data. package that uses its own cloud resources, local cache, and conflict policy 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 manifest references it by the same stable `appId` slug. + 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. @@ -299,8 +300,9 @@ defaults are applied; output aliases such as `EvidenceRef` describe parsed data. 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`, optional `verifiedBy` (`cliVersion`, `mcpHealth`), - and `at`. + 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`. diff --git a/examples/rollout-record.invalid.json b/examples/rollout-record.invalid.json index a7cdff9..34a0887 100644 --- a/examples/rollout-record.invalid.json +++ b/examples/rollout-record.invalid.json @@ -1,15 +1,13 @@ { "schema": "hasna.rollout_record.v1", - "id": "rollout_freeze_blocked_wrong_result", + "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": "freeze-blocked", + "action": "update", "result": "succeeded", - "verifiedBy": { - "cliVersion": "0.11.63" - }, + "verifiedBy": {}, "at": "2026-07-06T10:00:00.000Z" } diff --git a/src/no-cloud.ts b/src/no-cloud.ts index c064e98..4f4c87b 100644 --- a/src/no-cloud.ts +++ b/src/no-cloud.ts @@ -55,9 +55,15 @@ const CONTRACTS_DECLARATION_PATHS = new Set([ "dist/schemas.js", "dist/validators.js", "dist/index.js", + "dist/mode.js", + "dist/service-contract.js", + "dist/conformance.js", "dist/cli/index.js", "dist/no-cloud.d.ts", - "dist/schemas.d.ts" + "dist/schemas.d.ts", + "dist/mode.d.ts", + "dist/service-contract.d.ts", + "dist/conformance.d.ts" ]); function stableId(input: string) { diff --git a/src/schemas.ts b/src/schemas.ts index 9fdb37d..1cea078 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1229,7 +1229,15 @@ export const RolloutVerificationSchema = z cliVersion: z.string().min(1).optional(), mcpHealth: z.enum(["ok", "degraded", "unavailable", "not_checked"]).optional() }) - .strict(); + .strict() + .superRefine((value, ctx) => { + if (!value.cliVersion && value.mcpHealth === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Rollout verification requires at least one concrete verifier field" + }); + } + }); export type RolloutVerification = z.infer; export const RolloutRecordSchema = contractBaseSchema(SCHEMA_IDS.rolloutRecord) @@ -1253,10 +1261,18 @@ export const RolloutRecordSchema = contractBaseSchema(SCHEMA_IDS.rolloutRecord) path: ["result"] }); } - if ((value.action === "install" || value.action === "update") && value.result === "succeeded" && !value.verifiedBy) { + const hasConcreteVerification = + Boolean(value.verifiedBy?.cliVersion) || + (value.verifiedBy?.mcpHealth !== undefined && value.verifiedBy.mcpHealth !== "not_checked"); + const hasVerifierFields = value.verifiedBy ? Object.keys(value.verifiedBy).length > 0 : false; + if ( + (value.action === "install" || value.action === "update") && + value.result === "succeeded" && + (!value.verifiedBy || (hasVerifierFields && !hasConcreteVerification)) + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Succeeded install/update rollout records require verifiedBy", + message: "Succeeded install/update rollout records require concrete verification", path: ["verifiedBy"] }); } @@ -1450,14 +1466,14 @@ export const AppCloudResourceSchema = z export type AppCloudResource = z.infer; // `hasna.app_cloud_manifest.v1` is NOT an identity schema. Canonical app -// identity lives in `hasna.app.v1`; this manifest references that document by -// its stable `appId` slug and only declares the app's cloud storage boundary. +// identity lives in `hasna.app.v1`; this v1 field remains a compatible +// non-empty reference string instead of adopting the stricter AppIdSchema. export const AppCloudManifestSchema = contractBaseSchema(SCHEMA_IDS.appCloudManifest) .extend({ packageName: z.string().min(1), packageVersion: z.string().min(1).optional(), - /** Stable app identity slug; references the app's `hasna.app.v1` document. */ - appId: AppIdSchema, + /** App identity reference; prefer AppIdSchema-compatible slugs for new manifests. */ + appId: z.string().min(1), repository: ResourcePointerSchema.optional(), storageMode: z.enum(["local_only", "app_owned_cloud", "hybrid_local_cache", "external_service"]), cloudBoundary: z.enum(["none", "app_owned", "external_service", "local_cache"]), diff --git a/tests/cli.test.ts b/tests/cli.test.ts index f3168c4..5b8580c 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -380,6 +380,33 @@ describe("contracts CLI", () => { expect(JSON.stringify(payload)).not.toContain(import.meta.dir); }); + test("allows only exact generated contracts declaration bundles to skip runtime edge scanning", () => { + const dir = mkdtempSync(join(tmpdir(), "contracts-no-cloud-")); + const declarationText = + "const markerA = 'FORBIDDEN_SHARED_CLOUD_RUNTIMES';\n" + + "const markerB = 'hasna.no_cloud_evidence_pack.v1';\n" + + "const runtime = '@hasna/cloud';\n"; + try { + mkdirSync(join(dir, "dist")); + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "@hasna/contracts", version: "0.4.1" })); + writeFileSync(join(dir, "dist", "mode.js"), declarationText); + writeFileSync(join(dir, "dist", "service-contract.js"), declarationText); + writeFileSync(join(dir, "dist", "conformance.js"), declarationText); + + const result = runContracts(["no-cloud-scan", "--json", dir]); + expect(result.exitCode).toBe(0); + expect(parseStdoutJson(result).verdict).toBe("passed"); + + writeFileSync(join(dir, "dist", "other.js"), declarationText); + const invalidResult = runContracts(["no-cloud-scan", "--json", dir]); + expect(invalidResult.exitCode).toBe(1); + const payload = parseStdoutJson(invalidResult); + expect(payload.findings.some((finding: { path: string; pattern: string }) => finding.path === "dist/other.js" && finding.pattern === "@hasna/cloud")).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("does not allow downstream files to bypass scanning with declaration markers", () => { const dir = mkdtempSync(join(tmpdir(), "contracts-no-cloud-")); try { diff --git a/tests/examples.test.ts b/tests/examples.test.ts index a9e5186..fab3eee 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -11,7 +11,7 @@ const expectedInvalidIssuePaths: Record = { "audience.invalid.json": ["definition.predicates.0.key"], "integration-ref.invalid.json": ["uri"], "release.invalid.json": ["evidenceRefs"], - "rollout-record.invalid.json": ["result"], + "rollout-record.invalid.json": ["verifiedBy"], "no-cloud-evidence-pack.invalid.json": ["checks", "checks", "findings"], "project-manifest.invalid.json": ["slug"], "project-panel.invalid.json": ["stateReason"], diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts index 88d211f..419f920 100644 --- a/tests/schemas.test.ts +++ b/tests/schemas.test.ts @@ -578,6 +578,13 @@ describe("core schemas", () => { }); expect(manifest.forbiddenSharedRuntimes).toEqual(["@hasna/cloud", "open-cloud"]); + const legacyAppId = AppCloudManifestSchema.parse({ + ...manifest, + id: "cloud_manifest_legacy_app_ref", + appId: "Open Todos Legacy" + }); + expect(legacyAppId.appId).toBe("Open Todos Legacy"); + const extendedForbiddenRuntimes = AppCloudManifestSchema.safeParse({ ...manifest, id: "cloud_manifest_extended_forbidden", @@ -1077,6 +1084,30 @@ describe("distribution schemas", () => { if (!unverifiedSuccess.success) { expect(unverifiedSuccess.error.issues.map((issue) => issue.path.join("."))).toContain("verifiedBy"); } + + const emptyVerificationSuccess = validateContract(SCHEMA_IDS.rolloutRecord, { + ...validRollout, + verifiedBy: {} + }); + expect(emptyVerificationSuccess.success).toBe(false); + if (!emptyVerificationSuccess.success) { + expect(emptyVerificationSuccess.error.issues.map((issue) => issue.path.join("."))).toContain("verifiedBy"); + } + + const notCheckedOnlySuccess = validateContract(SCHEMA_IDS.rolloutRecord, { + ...validRollout, + verifiedBy: { mcpHealth: "not_checked" } + }); + expect(notCheckedOnlySuccess.success).toBe(false); + if (!notCheckedOnlySuccess.success) { + expect(notCheckedOnlySuccess.error.issues.map((issue) => issue.path.join("."))).toContain("verifiedBy"); + } + + const notCheckedWithCliVersion = validateContract(SCHEMA_IDS.rolloutRecord, { + ...validRollout, + verifiedBy: { cliVersion: "0.11.63", mcpHealth: "not_checked" } + }); + expect(notCheckedWithCliVersion.success).toBe(true); }); test("validates announcements with per-channel delivery status", () => { @@ -1168,7 +1199,7 @@ describe("distribution schemas", () => { expect(validateContract(SCHEMA_IDS.audience, { ...base, definition: { predicates: [] } }).success).toBe(false); }); - test("app cloud manifests reference canonical hasna.app.v1 identity by appId slug", () => { + test("app cloud manifests preserve v1 appId compatibility while app identity stays strict", () => { const manifest = { schema: SCHEMA_IDS.appCloudManifest, id: "cloud_manifest_open_todos", @@ -1179,6 +1210,7 @@ describe("distribution schemas", () => { cloudBoundary: "none" } as const; expect(validateContract(SCHEMA_IDS.appCloudManifest, manifest).success).toBe(true); - expect(validateContract(SCHEMA_IDS.appCloudManifest, { ...manifest, appId: "Open Todos!" }).success).toBe(false); + expect(validateContract(SCHEMA_IDS.appCloudManifest, { ...manifest, appId: "Open Todos!" }).success).toBe(true); + expect(validateContract(SCHEMA_IDS.app, { ...validApp, appId: "Open Todos!" }).success).toBe(false); }); });