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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <verified token>`
- `X-Tenant-Id: <optional consistency check>`
- `X-Correlation-Id: <optional correlation id>`
Expand Down
85 changes: 77 additions & 8 deletions packages/contracts/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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
47 changes: 47 additions & 0 deletions tests/http-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down