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
54 changes: 54 additions & 0 deletions .changeset/http-contract-unification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
"@objectstack/spec": patch
"@objectstack/core": patch
"@objectstack/cloud-connection": patch
"@objectstack/metadata": patch
"@objectstack/plugin-hono-server": patch
---

fix(spec,core,cloud-connection,metadata): one HTTP contract, one canonical slot name — and the dead shadow copy that helped cause the false exemption is deleted (#4251)

**`packages/core/src/contracts/` was a dead near-copy of the real contracts,
and it is gone.** The directory (http-server.ts, data-engine.ts, logger.ts) had
ZERO importers — no relative import, no subpath export, not a tsup entry;
core's barrel has re-exported the `@objectstack/spec/contracts` versions all
along ("Re-export contracts from @objectstack/spec for backward
compatibility"). But the shadow had already **diverged** from the live
contract (spec's `IHttpResponse` grew `write?`/`end?` and `IHttpRequest` grew
`rawBody?`; the copy never did), so anyone who grepped their way into it read a
stale contract that nothing enforces — the exact both-humans-and-AI failure
mode behind the false `http.server` exemption (#4382). Deleting it is
zero-risk by construction: nothing could reach it.

**`http.server` is the canonical slot name, and the ledger now says so.**
`ServiceSlotContracts` gains `'http.server': IHttpServer` plus the deprecated
`'http-server'` alias entry (same instance — hono-plugin and qa's node-plugin
register both two lines apart; cloud's two server entrypoints do the same).
Canonical is the only name present on EVERY provider path: runtime's
`config.server` path registers no alias, so the three cloud-connection plugins
that read the alias alone (marketplace-proxy, runtime-config,
marketplace-install-local) found an empty slot there — a live miss, now fixed:
all readers go canonical-first with the alias as a fallback that dies with the
alias registrations. The registrations themselves are untouched this release;
both sites now carry the deprecation note.

**`getRawApp?(): any` joins `IHttpServer`** — the deliberate framework-handle
escape, declared once. Four consumers were each declaring it locally
(cloud-connection ×2, metadata's HMR routes, cloud's serverless node-server);
those local `RawAppHost`/`HttpServerWithRawApp` types are deleted. The `any`
return is deliberate and documented at the single declaration: the handle's
real type belongs to the framework, and naming it would give the contract a
framework dependency. Adapters are not required to expose it; consumers
feature-detect.

**`IMetadataService.bulkRegister`/`bulkUnregister` declare the write options
their implementation has always accepted.** `bulkRegister`'s contract options
dropped the `MetadataWriteOptions` half its implementation intersects in
(`notify` is destructured on the method's first line); `bulkUnregister`
declared no options at all while the manager takes them. Same shape as the
`IDataEngine` read-methods gap from B2: a caller typed to the contract could
not reach the channel without erasing the lookup. Both additive; no implementor
or caller breaks.

Slot-lookup baseline ratchets 168 → 167 (marketplace-install-local's lookup
typed while touched).
17 changes: 12 additions & 5 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { resolveMarketplacePublicBaseUrl } from './marketplace-public-url.js';
import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js';
import { ConnectionCredentialStore } from './connection-credential-store.js';
import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js';
import type { IHttpServer } from '@objectstack/spec/contracts';

const ROUTE_BASE = '/api/v1/marketplace/install-local';

Expand Down Expand Up @@ -120,14 +121,20 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
await this.rehydrate(ctx);

// 2. Mount HTTP endpoints.
let httpServer: any;
try {
httpServer = ctx.getService('http-server');
} catch {
// [#4251] Read canonical-first with a REAL per-name fallback:
// `getService` THROWS for an empty slot, so a single try around
// `canonical ?? alias` never reaches the alias — the shape the old
// alias-first read had too, meaning its fallback never once fired.
const readServer = (name: string): IHttpServer | undefined => {
try { return ctx.getService<IHttpServer>(name); } catch { return undefined; }
};
// Canonical first — see marketplace-proxy-plugin.
const httpServer = readServer('http.server') ?? readServer('http-server');
if (!httpServer) {
ctx.logger?.warn?.('[MarketplaceInstallLocal] http-server not available — install endpoints not mounted');
return;
}
if (!httpServer || typeof httpServer.getRawApp !== 'function') {
if (typeof httpServer.getRawApp !== 'function') {
ctx.logger?.warn?.('[MarketplaceInstallLocal] http-server missing getRawApp() — install endpoints not mounted');
return;
}
Expand Down
30 changes: 12 additions & 18 deletions packages/cloud-connection/src/marketplace-proxy-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,6 @@ import {

import type { IHttpServer } from '@objectstack/spec/contracts';

/**
* The `http-server` slot plus the one member this plugin needs from it.
*
* [#4251] `IHttpServer` is a real contract (`@objectstack/spec/contracts`) and
* this slot is bound to it — but `getRawApp()` is NOT on it, deliberately: the
* contract is framework-agnostic and the raw app is the framework's own handle
* (Hono here). So the escape is one named member returning `any`, scoped to the
* one thing that genuinely cannot be typed framework-agnostically, instead of
* the whole lookup collapsing to `any`. The probe below is what runs when a
* host serves the slot with an adapter that exposes no raw app.
*/
type RawAppHost = IHttpServer & { getRawApp?(): any };

const MARKETPLACE_PREFIX = '/api/v1/marketplace';

/**
Expand Down Expand Up @@ -196,14 +183,21 @@ export class MarketplaceProxyPlugin implements Plugin {
manifest?.register?.(MARKETPLACE_BROWSE_UI_BUNDLE);
} catch { /* no manifest service */ }

let httpServer: RawAppHost | undefined;
try {
httpServer = ctx.getService<RawAppHost>('http-server');
} catch {
// [#4251] Read canonical-first with a REAL per-name fallback:
// `getService` THROWS for an empty slot, so a single try around
// `canonical ?? alias` never reaches the alias — the shape the old
// alias-first read had too, meaning its fallback never once fired.
const readServer = (name: string): IHttpServer | undefined => {
try { return ctx.getService<IHttpServer>(name); } catch { return undefined; }
};
// `http.server` is canonical (the only name every provider
// registers); the alias fallback dies with the alias registrations.
const httpServer = readServer('http.server') ?? readServer('http-server');
if (!httpServer) {
ctx.logger?.warn?.('[MarketplaceProxyPlugin] http-server not available — marketplace routes not mounted');
return;
}
if (!httpServer || typeof httpServer.getRawApp !== 'function') {
if (typeof httpServer.getRawApp !== 'function') {
ctx.logger?.warn?.('[MarketplaceProxyPlugin] http-server missing getRawApp() — marketplace routes not mounted');
return;
}
Expand Down
29 changes: 11 additions & 18 deletions packages/cloud-connection/src/runtime-config-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,6 @@ import type { Plugin, PluginContext } from '@objectstack/core';
import { resolveCloudUrl } from './cloud-url.js';
import type { IHttpServer } from '@objectstack/spec/contracts';

/**
* The `http-server` slot plus the one member this plugin needs from it.
*
* [#4251] `IHttpServer` is a real contract (`@objectstack/spec/contracts`) and
* this slot is bound to it — but `getRawApp()` is NOT on it, deliberately: the
* contract is framework-agnostic and the raw app is the framework's own handle
* (Hono here). So the escape is one named member returning `any`, scoped to the
* one thing that genuinely cannot be typed framework-agnostically, instead of
* the whole lookup collapsing to `any`. The probe below is what runs when a
* host serves the slot with an adapter that exposes no raw app.
*/
type RawAppHost = IHttpServer & { getRawApp?(): any };

/**
* The `env-registry` slot's hostname resolvers — see the call site for both
* spellings. Async by contract: the consumer awaits the result, and declaring
Expand Down Expand Up @@ -196,14 +183,20 @@ export class RuntimeConfigPlugin implements Plugin {

start = async (ctx: PluginContext): Promise<void> => {
ctx.hook('kernel:ready', async () => {
let httpServer: RawAppHost | undefined;
try {
httpServer = ctx.getService<RawAppHost>('http-server');
} catch {
// [#4251] Read canonical-first with a REAL per-name fallback:
// `getService` THROWS for an empty slot, so a single try around
// `canonical ?? alias` never reaches the alias — the shape the old
// alias-first read had too, meaning its fallback never once fired.
const readServer = (name: string): IHttpServer | undefined => {
try { return ctx.getService<IHttpServer>(name); } catch { return undefined; }
};
// Canonical first — see marketplace-proxy-plugin.
const httpServer = readServer('http.server') ?? readServer('http-server');
if (!httpServer) {
ctx.logger?.warn?.('[RuntimeConfigPlugin] http-server not available — runtime/config not mounted');
return;
}
if (!httpServer || typeof httpServer.getRawApp !== 'function') {
if (typeof httpServer.getRawApp !== 'function') {
ctx.logger?.warn?.('[RuntimeConfigPlugin] http-server missing getRawApp() — runtime/config not mounted');
return;
}
Expand Down
62 changes: 0 additions & 62 deletions packages/core/src/contracts/data-engine.ts

This file was deleted.

151 changes: 0 additions & 151 deletions packages/core/src/contracts/http-server.ts

This file was deleted.

Loading
Loading