From 4777ea62fe4d5a6fa0d10cf6a1f25e779c237c51 Mon Sep 17 00:00:00 2001 From: tuzuminami Date: Mon, 13 Jul 2026 20:14:35 +0900 Subject: [PATCH] fix: align ASTER OpenAPI with HTTP runtime --- CHANGELOG.md | 4 ++ README.md | 4 ++ packages/contracts/openapi/openapi.yaml | 85 ++++++++++++++++++++++--- tests/http-e2e.test.ts | 47 ++++++++++++++ 4 files changed, 132 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4897fc..fc5f99a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Align the public OpenAPI tenant assertion and plugin-validation request contract with the executable HTTP runtime. + ## 1.0.0 - 2026-07-13 - Finalized ASTER as the portable Persona Contract Compiler support module. diff --git a/README.md b/README.md index f7dca37..949c29a 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,10 @@ string alone is never authorization proof. `X-Tenant-Id` is optional and, when s the verified tenant. Production startup requires a verified auth adapter and explicit durable-storage assertion, and rejects the development adapter and a wildcard network binding. +The OpenAPI contract mirrors this ownership model: generated clients may omit `X-Tenant-Id`; they must +send a valid bearer token and may use the header only as an optional consistency assertion. The +`POST /v1/plugins/validate` request body is the versioned `PluginManifest` schema exposed in OpenAPI. + - `Authorization: Bearer ` - `X-Tenant-Id: ` - `X-Correlation-Id: ` diff --git a/packages/contracts/openapi/openapi.yaml b/packages/contracts/openapi/openapi.yaml index 6525901..15b003d 100644 --- a/packages/contracts/openapi/openapi.yaml +++ b/packages/contracts/openapi/openapi.yaml @@ -12,7 +12,7 @@ paths: post: summary: Create a persona draft container. parameters: - - $ref: "#/components/parameters/TenantId" + - $ref: "#/components/parameters/TenantAssertion" - $ref: "#/components/parameters/CorrelationId" - $ref: "#/components/parameters/IdempotencyKey" security: @@ -36,7 +36,7 @@ paths: post: summary: Create a draft persona version from a Persona Contract. parameters: - - $ref: "#/components/parameters/TenantId" + - $ref: "#/components/parameters/TenantAssertion" - $ref: "#/components/parameters/CorrelationId" - $ref: "#/components/parameters/IdempotencyKey" - name: personaId @@ -65,7 +65,7 @@ paths: post: summary: Publish an immutable persona version. parameters: - - $ref: "#/components/parameters/TenantId" + - $ref: "#/components/parameters/TenantAssertion" - $ref: "#/components/parameters/CorrelationId" - $ref: "#/components/parameters/IdempotencyKey" - name: personaId @@ -89,7 +89,7 @@ paths: post: summary: Compile a published persona version into a deterministic bundle. parameters: - - $ref: "#/components/parameters/TenantId" + - $ref: "#/components/parameters/TenantAssertion" - $ref: "#/components/parameters/CorrelationId" - $ref: "#/components/parameters/IdempotencyKey" - name: personaId @@ -113,7 +113,7 @@ paths: get: summary: Diff two persona versions. parameters: - - $ref: "#/components/parameters/TenantId" + - $ref: "#/components/parameters/TenantAssertion" - $ref: "#/components/parameters/CorrelationId" - name: personaId in: path @@ -139,26 +139,39 @@ paths: post: summary: Validate and register a plugin manifest for this process. parameters: - - $ref: "#/components/parameters/TenantId" + - $ref: "#/components/parameters/TenantAssertion" - $ref: "#/components/parameters/CorrelationId" - $ref: "#/components/parameters/IdempotencyKey" security: - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PluginManifest" responses: "200": description: Plugin accepted. "409": description: Idempotency key was reused for a different request. + "401": + $ref: "#/components/responses/AuthenticationRequired" + "403": + $ref: "#/components/responses/AuthorizationDenied" + "422": + $ref: "#/components/responses/ValidationFailed" components: securitySchemes: bearerAuth: type: http scheme: bearer parameters: - TenantId: + TenantAssertion: name: X-Tenant-Id in: header - required: true + required: false + description: Optional caller assertion. The authenticated principal owns tenant identity; a supplied value must match it. schema: type: string CorrelationId: @@ -173,3 +186,59 @@ components: required: true schema: type: string + schemas: + PluginManifest: + type: object + required: [name, version, capabilities, coreApiVersion, enabled] + properties: + name: + type: string + minLength: 1 + version: + type: string + minLength: 1 + capabilities: + type: array + items: + type: string + enum: [context_injector, renderer] + coreApiVersion: + type: string + const: v1 + enabled: + type: boolean + responses: + AuthenticationRequired: + description: The bearer token was absent or could not be verified. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + AuthorizationDenied: + description: The authenticated principal lacks the required scope or asserted a different tenant. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + ValidationFailed: + description: The request body does not satisfy the endpoint contract. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: + type: string + message: + type: string + details: + type: array + items: + type: string diff --git a/tests/http-e2e.test.ts b/tests/http-e2e.test.ts index cac588b..b87ef86 100644 --- a/tests/http-e2e.test.ts +++ b/tests/http-e2e.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import test from "node:test"; import { createAsterServer, handleAsterRequest, type AsterIncomingRequest, type AsterOutgoingResponse } from "../apps/api/src/http.ts"; import type { AsterAuthAdapter } from "../apps/api/src/auth.ts"; @@ -212,6 +213,52 @@ test("AT-AST-016 production startup refuses development auth, in-memory storage, } }); +test("AT-AST-021 OpenAPI-valid plugin requests derive tenancy from the verified principal", async () => { + const openApi = readFileSync("packages/contracts/openapi/openapi.yaml", "utf8"); + assert.match(openApi, /TenantAssertion:\n name: X-Tenant-Id\n in: header\n required: false/); + assert.match(openApi, /\/v1\/plugins\/validate:[\s\S]*?requestBody:\n required: true[\s\S]*?\$ref: "#\/components\/schemas\/PluginManifest"/); + assert.match(openApi, /"401":\n \$ref: "#\/components\/responses\/AuthenticationRequired"/); + assert.match(openApi, /"403":\n \$ref: "#\/components\/responses\/AuthorizationDenied"/); + assert.match(openApi, /"422":\n \$ref: "#\/components\/responses\/ValidationFailed"/); + + const { service } = makeService(); + const verifiedAuth: AsterAuthAdapter = { + async authenticate() { + return { actorId: "verified_actor", tenantId: "tenant_verified", scopes: ["aster:plugins:write"] }; + } + }; + const plugin = { + name: "renderer", + version: "1.0.0", + capabilities: ["renderer"], + coreApiVersion: "v1", + enabled: true + }; + const accepted = await requestJson(service, "/v1/plugins/validate", { + method: "POST", + body: plugin, + headers: { authorization: "Bearer verified", "idempotency-key": "verified-plugin" }, + authAdapter: verifiedAuth + }); + assert.equal(accepted.status, 200); + + const rejectedBody = await requestJson(service, "/v1/plugins/validate", { + method: "POST", + body: { ...plugin, coreApiVersion: "v2" }, + headers: { authorization: "Bearer verified", "idempotency-key": "invalid-plugin" }, + authAdapter: verifiedAuth + }); + assert.equal(rejectedBody.status, 422); + + const rejectedTenant = await requestJson(service, "/v1/plugins/validate", { + method: "POST", + body: plugin, + headers: { authorization: "Bearer verified", "x-tenant-id": "tenant_other", "idempotency-key": "mismatched-plugin" }, + authAdapter: verifiedAuth + }); + assert.equal(rejectedTenant.status, 403); +}); + const requestJson = async ( service: AsterService, path: string,