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
31 changes: 31 additions & 0 deletions .changeset/provision-hostname-assignment.md
Original file line number Diff line number Diff line change
@@ -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 响应(不带该键的)照旧合法,无需改动任何调用方。
3 changes: 2 additions & 1 deletion content/docs/references/cloud/environment.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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". |


---
Expand Down Expand Up @@ -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 |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ directory rather than per file.
|---|---|
| `ai/` | 77 |
| `api/` | 396 |
| `cloud/` | 82 |
| `cloud/` | 83 |
| `identity/` | 33 |
| `integration/` | 10 |
| `kernel/` | 319 |
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/cloud.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
91 changes: 91 additions & 0 deletions packages/spec/src/cloud/environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
EnvironmentMemberSchema,
EnvironmentRoleSchema,
ProvisionEnvironmentRequestSchema,
ProvisionEnvironmentResponseSchema,
ProvisionOrganizationRequestSchema,
} from './environment.zod';
describe('EnvironmentStatusSchema', () => {
Expand Down Expand Up @@ -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<string, unknown>).renamedHostname).toBeUndefined();
expect(Object.keys(parsed)).not.toContain('renamedHostname');
});
});

describe('ProvisionOrganizationRequestSchema', () => {
it('applies default defaultEnvironmentDisplayName', () => {
const parsed = ProvisionOrganizationRequestSchema.parse({
Expand Down
27 changes: 27 additions & 0 deletions packages/spec/src/cloud/environment.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ProvisionEnvironmentResponseSchema>;
Expand Down
Loading