diff --git a/.changeset/provision-hostname-assignment.md b/.changeset/provision-hostname-assignment.md new file mode 100644 index 0000000000..4689a913fb --- /dev/null +++ b/.changeset/provision-hostname-assignment.md @@ -0,0 +1,31 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `ProvisionEnvironmentResponse` 增加可选 `hostnameAssignment` —— 自动改名的响亮回执 (#5185) + +环境的 canonical hostname 是 UNIQUE 的。provision 时若请求的 hostname 撞车,控制面不会让整个 +调用失败,而是追加一小段后缀自动改名。改名以前**只体现在返回的 `environment.hostname` 上**—— +调用方除非自己把请求的 hostname 存下来再逐字符比对,否则无从知道自己拿到的并不是自己要的那个。 + +`ProvisionEnvironmentResponseSchema` 新增可选字段: + +```ts +hostnameAssignment: { + requestedHostname: string; // 调用方要的(显式传入,或由 displayName 推导) + assignedHostname: string; // 改名后实际分配的,等于 environment.hostname +} +``` + +**语义是「仅在真的发生了自动改名时才填充」**:没撞车、原样分配的调用完全不带这个键,因此 +`hostnameAssignment !== undefined` 本身就是信号。⛔ 不要把「缺席」读成「未知」。 + +为什么必须声明在 spec、而不是控制面本地 extend(cloud#1070 方案 C,维护者已批): + +- 控制面本地 extend 出来的是**未声明的兄弟键**,`z.object` 出站即剥离 —— 回执靠蒸发「合规」; +- 塞进自由格式的 `metadata` 袋:在 caller-wins 先例下,调用方可以压制、也可以伪造这个由服务端 + 发出的信任信号; +- AI 生成的 provisioning 客户端按本协议已发布的表面(`api-surface.json`)生成 —— 字段不进 + spec 表面,生成出来的客户端永远不会去读它。 + +纯增量的可选字段:既有的 provisioning 响应(不带该键的)照旧合法,无需改动任何调用方。 diff --git a/content/docs/references/cloud/environment.mdx b/content/docs/references/cloud/environment.mdx index a507ec1b29..9ede6109ec 100644 --- a/content/docs/references/cloud/environment.mdx +++ b/content/docs/references/cloud/environment.mdx @@ -234,6 +234,7 @@ Public exposure of this environment artifacts (private | unlisted | public). | **credential** | `{ id: string; environmentId: string; secretCiphertext: string; encryptionKeyId: string; … }` | ✅ | Freshly-minted credential for the environment DB | | **durationMs** | `number` | ✅ | Total provisioning duration in milliseconds | | **warnings** | `string[]` | optional | Non-fatal warnings emitted during provisioning | +| **hostnameAssignment** | `{ requestedHostname: string; assignedHostname: string }` | optional | Populated ONLY when the control plane auto-renamed the requested hostname to avoid a collision. Absent means the requested hostname was assigned unchanged — never read absence as "unknown". | --- @@ -261,7 +262,7 @@ Public exposure of this environment artifacts (private | unlisted | public). | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **defaultEnvironment** | `{ environment: object; credential: object; durationMs: number; warnings?: string[] }` | ✅ | Default environment that was created | +| **defaultEnvironment** | `{ environment: object; credential: object; durationMs: number; warnings?: string[]; … }` | ✅ | Default environment that was created | | **durationMs** | `number` | ✅ | Total bootstrap duration in milliseconds | | **warnings** | `string[]` | optional | Non-fatal warnings | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index fae2e8813e..c360ecd3c5 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -263,7 +263,7 @@ directory rather than per file. |---|---| | `ai/` | 77 | | `api/` | 396 | -| `cloud/` | 82 | +| `cloud/` | 83 | | `identity/` | 33 | | `integration/` | 10 | | `kernel/` | 319 | diff --git a/packages/spec/authorable-surface/cloud.json b/packages/spec/authorable-surface/cloud.json index 464357000b..d616018422 100644 --- a/packages/spec/authorable-surface/cloud.json +++ b/packages/spec/authorable-surface/cloud.json @@ -329,6 +329,7 @@ "cloud/ProvisionEnvironmentResponse:credential", "cloud/ProvisionEnvironmentResponse:durationMs", "cloud/ProvisionEnvironmentResponse:environment", + "cloud/ProvisionEnvironmentResponse:hostnameAssignment", "cloud/ProvisionEnvironmentResponse:warnings", "cloud/ProvisionOrganizationRequest:createdBy", "cloud/ProvisionOrganizationRequest:defaultEnvironmentDisplayName", diff --git a/packages/spec/src/cloud/environment.test.ts b/packages/spec/src/cloud/environment.test.ts index 55115d1349..2c0b4d5947 100644 --- a/packages/spec/src/cloud/environment.test.ts +++ b/packages/spec/src/cloud/environment.test.ts @@ -9,6 +9,7 @@ import { EnvironmentMemberSchema, EnvironmentRoleSchema, ProvisionEnvironmentRequestSchema, + ProvisionEnvironmentResponseSchema, ProvisionOrganizationRequestSchema, } from './environment.zod'; describe('EnvironmentStatusSchema', () => { @@ -163,6 +164,96 @@ describe('ProvisionEnvironmentRequestSchema', () => { }); }); +describe('ProvisionEnvironmentResponseSchema — hostnameAssignment', () => { + const environment = { + id: '550e8400-e29b-41d4-a716-446655440000', + organizationId: 'org_1', + displayName: 'Alice dev', + isDefault: false, + plan: 'pro' as const, + status: 'active' as const, + createdBy: 'user_1', + createdAt: '2026-04-19T00:00:00.000Z', + updatedAt: '2026-04-19T00:00:00.000Z', + hostname: 'alice-dev-9f3a.objectstack.app', + }; + const credential = { + id: '550e8400-e29b-41d4-a716-446655440001', + environmentId: '550e8400-e29b-41d4-a716-446655440000', + secretCiphertext: 'ciphertext', + encryptionKeyId: 'kms-key-1', + createdAt: '2026-04-19T00:00:00.000Z', + }; + const base = { environment, credential, durationMs: 1234 }; + + it('parses a response WITHOUT hostnameAssignment (no rename happened)', () => { + const parsed = ProvisionEnvironmentResponseSchema.parse(base); + expect(parsed.hostnameAssignment).toBeUndefined(); + }); + + it('parses a response WITH hostnameAssignment and preserves both hostnames', () => { + const parsed = ProvisionEnvironmentResponseSchema.parse({ + ...base, + hostnameAssignment: { + requestedHostname: 'alice-dev.objectstack.app', + assignedHostname: 'alice-dev-9f3a.objectstack.app', + }, + }); + expect(parsed.hostnameAssignment).toEqual({ + requestedHostname: 'alice-dev.objectstack.app', + assignedHostname: 'alice-dev-9f3a.objectstack.app', + }); + }); + + it('rejects hostnameAssignment missing assignedHostname', () => { + expect(() => + ProvisionEnvironmentResponseSchema.parse({ + ...base, + hostnameAssignment: { requestedHostname: 'alice-dev.objectstack.app' }, + }), + ).toThrow(); + }); + + it('rejects hostnameAssignment missing requestedHostname', () => { + expect(() => + ProvisionEnvironmentResponseSchema.parse({ + ...base, + hostnameAssignment: { assignedHostname: 'alice-dev-9f3a.objectstack.app' }, + }), + ).toThrow(); + }); + + it('rejects non-string hostnames inside hostnameAssignment', () => { + expect(() => + ProvisionEnvironmentResponseSchema.parse({ + ...base, + hostnameAssignment: { requestedHostname: 'a.objectstack.app', assignedHostname: 42 }, + }), + ).toThrow(); + }); + + it('rejects a non-object hostnameAssignment', () => { + expect(() => + ProvisionEnvironmentResponseSchema.parse({ + ...base, + hostnameAssignment: 'alice-dev-9f3a.objectstack.app', + }), + ).toThrow(); + }); + + // Why the field has to be declared HERE and not extended on in the control + // plane: an undeclared sibling key is stripped by `z.object`, so a + // cloud-local rename receipt would silently evaporate on the way out. + it('strips an undeclared sibling key — the reason this field is declared in spec', () => { + const parsed = ProvisionEnvironmentResponseSchema.parse({ + ...base, + renamedHostname: 'alice-dev-9f3a.objectstack.app', + }); + expect((parsed as Record).renamedHostname).toBeUndefined(); + expect(Object.keys(parsed)).not.toContain('renamedHostname'); + }); +}); + describe('ProvisionOrganizationRequestSchema', () => { it('applies default defaultEnvironmentDisplayName', () => { const parsed = ProvisionOrganizationRequestSchema.parse({ diff --git a/packages/spec/src/cloud/environment.zod.ts b/packages/spec/src/cloud/environment.zod.ts index 994593b755..4ebbb0355a 100644 --- a/packages/spec/src/cloud/environment.zod.ts +++ b/packages/spec/src/cloud/environment.zod.ts @@ -317,6 +317,33 @@ export const ProvisionEnvironmentResponseSchema = lazySchema(() => z.object({ credential: EnvironmentCredentialSchema.describe('Freshly-minted credential for the environment DB'), durationMs: z.number().describe('Total provisioning duration in milliseconds'), warnings: z.array(z.string()).optional().describe('Non-fatal warnings emitted during provisioning'), + /** + * Loud rename receipt: the control plane auto-renames a colliding hostname + * (canonical hostnames are UNIQUE) by appending a short suffix instead of + * failing the call. Present ONLY when that rename actually happened — a + * provisioning call that got the hostname it asked for omits this key + * entirely, so `hostnameAssignment !== undefined` is itself the signal. + * + * Declared here rather than in the control plane so the fact survives + * `z.object` stripping and reaches generated provisioning clients, which + * are built from this protocol's published surface (`api-surface.json`). + * It is deliberately NOT carried in the free-form `metadata` bag: a caller + * must not be able to suppress or forge a trust signal the server emits. + */ + hostnameAssignment: z.object({ + requestedHostname: z + .string() + .describe('Hostname the caller asked for (explicitly, or as auto-derived from displayName)'), + assignedHostname: z + .string() + .describe('Hostname actually assigned after the collision-avoiding rename; equals `environment.hostname`'), + }) + .optional() + .describe( + 'Populated ONLY when the control plane auto-renamed the requested hostname to avoid a ' + + 'collision. Absent means the requested hostname was assigned unchanged — never read ' + + 'absence as "unknown".', + ), })); export type ProvisionEnvironmentResponse = z.infer;