@smartcrab/contracts-management 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/dist/api-keys.d.ts +251 -0
- package/dist/api-keys.js +78 -0
- package/dist/audit-events.d.ts +94 -0
- package/dist/audit-events.js +45 -0
- package/dist/billing.d.ts +30 -0
- package/dist/billing.js +30 -0
- package/dist/clients.d.ts +328 -0
- package/dist/clients.js +141 -0
- package/dist/common.d.ts +95 -0
- package/dist/common.js +99 -0
- package/dist/connections.d.ts +201 -0
- package/dist/connections.js +107 -0
- package/dist/domains.d.ts +167 -0
- package/dist/domains.js +74 -0
- package/dist/environments.d.ts +194 -0
- package/dist/environments.js +101 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +27 -0
- package/dist/primitives.d.ts +49 -0
- package/dist/primitives.js +56 -0
- package/dist/projects.d.ts +78 -0
- package/dist/projects.js +49 -0
- package/dist/rbac.d.ts +222 -0
- package/dist/rbac.js +226 -0
- package/dist/usage.d.ts +72 -0
- package/dist/usage.js +52 -0
- package/dist/user-jobs.d.ts +253 -0
- package/dist/user-jobs.js +95 -0
- package/dist/user-subresources.d.ts +161 -0
- package/dist/user-subresources.js +85 -0
- package/dist/users.d.ts +193 -0
- package/dist/users.js +112 -0
- package/dist/webhooks.d.ts +306 -0
- package/dist/webhooks.js +134 -0
- package/dist/workspaces.d.ts +172 -0
- package/dist/workspaces.js +77 -0
- package/package.json +42 -0
- package/src/api-keys.test.ts +86 -0
- package/src/api-keys.ts +97 -0
- package/src/audit-events.test.ts +44 -0
- package/src/audit-events.ts +57 -0
- package/src/billing.test.ts +57 -0
- package/src/billing.ts +46 -0
- package/src/clients.test.ts +169 -0
- package/src/clients.ts +182 -0
- package/src/common.test.ts +102 -0
- package/src/common.ts +127 -0
- package/src/connections.test.ts +65 -0
- package/src/connections.ts +134 -0
- package/src/domains.test.ts +48 -0
- package/src/domains.ts +104 -0
- package/src/environments.test.ts +67 -0
- package/src/environments.ts +133 -0
- package/src/index.ts +27 -0
- package/src/primitives.ts +100 -0
- package/src/projects.test.ts +49 -0
- package/src/projects.ts +72 -0
- package/src/rbac.test.ts +148 -0
- package/src/rbac.ts +250 -0
- package/src/secret-fields.test.ts +84 -0
- package/src/usage.test.ts +57 -0
- package/src/usage.ts +66 -0
- package/src/user-jobs.test.ts +86 -0
- package/src/user-jobs.ts +120 -0
- package/src/user-subresources.test.ts +115 -0
- package/src/user-subresources.ts +128 -0
- package/src/users.test.ts +91 -0
- package/src/users.ts +149 -0
- package/src/webhooks.test.ts +187 -0
- package/src/webhooks.ts +182 -0
- package/src/workspaces.test.ts +127 -0
- package/src/workspaces.ts +106 -0
package/src/common.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Common Management API envelope conventions (design.md §26.3):
|
|
5
|
+
*
|
|
6
|
+
* - cursor pagination, max page size 100
|
|
7
|
+
* - write APIs support `Idempotency-Key`
|
|
8
|
+
* - updates support ETag / `If-Match`
|
|
9
|
+
* - a Request ID is attached to every response
|
|
10
|
+
* - errors are returned as RFC 9457 Problem Details
|
|
11
|
+
*
|
|
12
|
+
* `request_id`: design.md §26.3's Problem Details example embeds
|
|
13
|
+
* `request_id` inside the error body. For success responses this package
|
|
14
|
+
* does not duplicate `request_id` into every resource/list schema — doing so
|
|
15
|
+
* would force every list item and every nested object to carry a redundant
|
|
16
|
+
* copy. Instead, success responses attach it the same way
|
|
17
|
+
* `@smartcrab/result`'s `problemDetailsResponse` already does for errors:
|
|
18
|
+
* an `X-Request-Id` response header (`requestIdHeaderSchema` below). Routes
|
|
19
|
+
* that need the id in the JSON body too may extend a resource schema with
|
|
20
|
+
* `problemDetailsSchema`'s `request_id` field; the header is the one
|
|
21
|
+
* guarantee this package encodes as "attached to all responses".
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Pagination (design.md §26.3: "cursor pagination", "最大page size 100")
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_PAGE_SIZE = 25;
|
|
29
|
+
export const MAX_PAGE_SIZE = 100;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Query parameters shared by every `GET` list route. `limit` is coerced from
|
|
33
|
+
* its wire representation (a query string) to a number so callers can parse
|
|
34
|
+
* `URLSearchParams` values directly.
|
|
35
|
+
*/
|
|
36
|
+
export const paginationQuerySchema = z.object({
|
|
37
|
+
cursor: z.string().min(1).max(2048).optional(),
|
|
38
|
+
limit: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE),
|
|
39
|
+
});
|
|
40
|
+
export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Generic cursor-paginated list envelope: `{ items, next_cursor }`.
|
|
44
|
+
* `next_cursor` is `null` once the caller has reached the last page.
|
|
45
|
+
*/
|
|
46
|
+
export const cursorPageSchema = <ItemSchema extends z.ZodType>(itemSchema: ItemSchema) =>
|
|
47
|
+
z.strictObject({
|
|
48
|
+
items: z.array(itemSchema),
|
|
49
|
+
next_cursor: z.string().min(1).nullable(),
|
|
50
|
+
});
|
|
51
|
+
export type CursorPage<Item> = {
|
|
52
|
+
readonly items: readonly Item[];
|
|
53
|
+
readonly next_cursor: string | null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Idempotency-Key (design.md §26.3: "write APIは`Idempotency-Key`対応")
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export const idempotencyKeySchema = z.string().min(1).max(255);
|
|
61
|
+
|
|
62
|
+
/** Header carried by every `POST`/`PATCH`/`DELETE` write request. */
|
|
63
|
+
export const idempotencyKeyHeaderSchema = z.strictObject({
|
|
64
|
+
"idempotency-key": idempotencyKeySchema,
|
|
65
|
+
});
|
|
66
|
+
export type IdempotencyKeyHeader = z.infer<typeof idempotencyKeyHeaderSchema>;
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// ETag / If-Match (design.md §26.3: "updateはETag / `If-Match`対応")
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/** An opaque entity tag, optionally weak (`W/"..."`) or quoted per RFC 9110 §8.8.3. */
|
|
73
|
+
export const etagSchema = z.string().min(1).max(512);
|
|
74
|
+
export type ETag = z.infer<typeof etagSchema>;
|
|
75
|
+
|
|
76
|
+
/** Header carried by every conditional `PATCH`/`DELETE` request. */
|
|
77
|
+
export const ifMatchHeaderSchema = z.strictObject({
|
|
78
|
+
"if-match": etagSchema,
|
|
79
|
+
});
|
|
80
|
+
export type IfMatchHeader = z.infer<typeof ifMatchHeaderSchema>;
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Request ID (design.md §26.3: "Request IDを全レスポンスへ付与")
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/** `req_...` (design.md §7.5 / `@smartcrab/identifiers` `ID_PREFIXES.request`). */
|
|
87
|
+
export const requestIdSchema = z.string().regex(/^req_[a-z2-7]{32}$/);
|
|
88
|
+
export type RequestId = z.infer<typeof requestIdSchema>;
|
|
89
|
+
|
|
90
|
+
export const requestIdHeaderSchema = z.strictObject({
|
|
91
|
+
"x-request-id": requestIdSchema,
|
|
92
|
+
});
|
|
93
|
+
export type RequestIdHeader = z.infer<typeof requestIdHeaderSchema>;
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Problem Details (design.md §26.3, RFC 9457)
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Matches `@smartcrab/result`'s `ProblemDetails` interface shape exactly
|
|
101
|
+
* (this package does not import `@smartcrab/result`, per assignment
|
|
102
|
+
* rules, so the shape is redefined here as a zod schema for request/response
|
|
103
|
+
* contract validation rather than reused as a TS type).
|
|
104
|
+
*
|
|
105
|
+
* ```json
|
|
106
|
+
* {
|
|
107
|
+
* "type": "https://docs.example-auth.com/errors/invalid-redirect-uri",
|
|
108
|
+
* "title": "Invalid redirect URI",
|
|
109
|
+
* "status": 400,
|
|
110
|
+
* "detail": "The redirect URI is not registered for this client.",
|
|
111
|
+
* "code": "invalid_redirect_uri",
|
|
112
|
+
* "request_id": "req_xxx"
|
|
113
|
+
* }
|
|
114
|
+
* ```
|
|
115
|
+
*
|
|
116
|
+
* Deliberately NOT `z.strictObject`: RFC 9457 explicitly allows arbitrary
|
|
117
|
+
* extension members alongside the standard ones.
|
|
118
|
+
*/
|
|
119
|
+
export const problemDetailsSchema = z.object({
|
|
120
|
+
type: z.string().min(1),
|
|
121
|
+
title: z.string().min(1),
|
|
122
|
+
status: z.number().int().min(100).max(599),
|
|
123
|
+
detail: z.string().optional(),
|
|
124
|
+
code: z.string().optional(),
|
|
125
|
+
request_id: requestIdSchema.optional(),
|
|
126
|
+
});
|
|
127
|
+
export type ProblemDetails = z.infer<typeof problemDetailsSchema>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
connectionCreateRequestSchema,
|
|
4
|
+
connectionCreateResponseSchema,
|
|
5
|
+
connectionSchema,
|
|
6
|
+
} from "./connections.js";
|
|
7
|
+
|
|
8
|
+
const VALID_CONNECTION = {
|
|
9
|
+
id: `con_${"a".repeat(32)}`,
|
|
10
|
+
environment_id: `env_${"a".repeat(32)}`,
|
|
11
|
+
provider: "google",
|
|
12
|
+
mode: "managed",
|
|
13
|
+
provider_key: "google-default",
|
|
14
|
+
issuer: null,
|
|
15
|
+
provider_client_id: "123456.apps.googleusercontent.com",
|
|
16
|
+
scopes: ["email", "profile"],
|
|
17
|
+
profile_mapping: {},
|
|
18
|
+
status: "active",
|
|
19
|
+
owner_confirmed: true,
|
|
20
|
+
created_at: "2026-08-02T08:10:00Z",
|
|
21
|
+
updated_at: "2026-08-02T08:10:00Z",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("connectionSchema (design.md §11.10)", () => {
|
|
25
|
+
test("accepts a valid connection", () => {
|
|
26
|
+
expect(connectionSchema.safeParse(VALID_CONNECTION).success).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("rejects an unknown top-level key (e.g. leaking secret_ciphertext)", () => {
|
|
30
|
+
expect(
|
|
31
|
+
connectionSchema.safeParse({ ...VALID_CONNECTION, secret_ciphertext: "abc" }).success,
|
|
32
|
+
).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("rejects secret_key_version leaking through (internal encryption generation)", () => {
|
|
36
|
+
expect(connectionSchema.safeParse({ ...VALID_CONNECTION, secret_key_version: 1 }).success).toBe(
|
|
37
|
+
false,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("connectionCreateRequestSchema / connectionCreateResponseSchema", () => {
|
|
43
|
+
test("create request accepts a write-only provider_client_secret", () => {
|
|
44
|
+
expect(
|
|
45
|
+
connectionCreateRequestSchema.safeParse({
|
|
46
|
+
environment_id: VALID_CONNECTION.environment_id,
|
|
47
|
+
provider: "generic_oidc",
|
|
48
|
+
mode: "byo",
|
|
49
|
+
provider_key: "acme-sso",
|
|
50
|
+
issuer: "https://sso.acme.example",
|
|
51
|
+
provider_client_id: "abc",
|
|
52
|
+
provider_client_secret: "super-secret-value",
|
|
53
|
+
}).success,
|
|
54
|
+
).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("create response never echoes provider_client_secret back", () => {
|
|
58
|
+
expect(
|
|
59
|
+
connectionCreateResponseSchema.safeParse({
|
|
60
|
+
...VALID_CONNECTION,
|
|
61
|
+
provider_client_secret: "leaked",
|
|
62
|
+
}).success,
|
|
63
|
+
).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { httpsUrlSchema, socialProviderSchema } from "@smartcrab/contracts-public";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { cursorPageSchema, paginationQuerySchema } from "./common.js";
|
|
4
|
+
import { connectionIdSchema, environmentIdSchema } from "./primitives.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `provider_connections` (design.md §11.10, §20). `secret_ciphertext`,
|
|
8
|
+
* `secret_iv`, and `secret_key_version` are excluded: the first two per the
|
|
9
|
+
* assignment's forbidden-field list, the third as the internal encryption
|
|
10
|
+
* key-rotation generation for those ciphertexts — the same "internal
|
|
11
|
+
* routing/security machinery" category, not a resource attribute a
|
|
12
|
+
* dashboard/API consumer needs.
|
|
13
|
+
*
|
|
14
|
+
* The provider's own OAuth `client_id` (design.md §11.10's `client_id`
|
|
15
|
+
* column) is renamed here to `provider_client_id` to avoid colliding with
|
|
16
|
+
* this platform's own `Client` resource (`clients.id`, §11.5) — the two are
|
|
17
|
+
* unrelated identifiers that happen to share a column name in the schema.
|
|
18
|
+
*/
|
|
19
|
+
export const CONNECTION_MODES = ["managed", "byo"] as const;
|
|
20
|
+
export const connectionModeSchema = z.enum(CONNECTION_MODES);
|
|
21
|
+
export type ConnectionMode = (typeof CONNECTION_MODES)[number];
|
|
22
|
+
|
|
23
|
+
export const CONNECTION_STATUSES = ["active", "disabled"] as const;
|
|
24
|
+
export const connectionStatusSchema = z.enum(CONNECTION_STATUSES);
|
|
25
|
+
export type ConnectionStatus = (typeof CONNECTION_STATUSES)[number];
|
|
26
|
+
|
|
27
|
+
/** A single non-whitespace OAuth scope token requested from the upstream provider. */
|
|
28
|
+
const providerScopeSchema = z.string().min(1).max(100).regex(/^\S+$/);
|
|
29
|
+
|
|
30
|
+
/** design.md §11.10 `provider_key`: distinguishes multiple connections of the same provider per environment. */
|
|
31
|
+
const providerKeySchema = z
|
|
32
|
+
.string()
|
|
33
|
+
.min(1)
|
|
34
|
+
.max(100)
|
|
35
|
+
.regex(/^[a-z0-9]+(?:[_-][a-z0-9]+)*$/);
|
|
36
|
+
|
|
37
|
+
/** Maps this platform's normalized profile fields to the provider's own claim/attribute names. */
|
|
38
|
+
const profileMappingSchema = z.record(z.string(), z.string());
|
|
39
|
+
|
|
40
|
+
export const connectionSchema = z.strictObject({
|
|
41
|
+
id: connectionIdSchema,
|
|
42
|
+
environment_id: environmentIdSchema,
|
|
43
|
+
provider: socialProviderSchema,
|
|
44
|
+
mode: connectionModeSchema,
|
|
45
|
+
provider_key: providerKeySchema,
|
|
46
|
+
/** Only meaningful for `provider: "generic_oidc"` (design.md §11.10 `issuer NULLABLE`). */
|
|
47
|
+
issuer: httpsUrlSchema.nullable(),
|
|
48
|
+
provider_client_id: z.string().min(1),
|
|
49
|
+
scopes: z.array(providerScopeSchema).max(20),
|
|
50
|
+
profile_mapping: profileMappingSchema,
|
|
51
|
+
status: connectionStatusSchema,
|
|
52
|
+
/**
|
|
53
|
+
* design.md §20.3: "Dashboard上で所有者が明示確認するまでConnectionを有効化
|
|
54
|
+
* しない" — a `generic_oidc` connection's owner-supplied issuer is a real
|
|
55
|
+
* SSRF surface, so it stays unusable until the Dashboard owner explicitly
|
|
56
|
+
* confirms it here. Always `true` for every other provider (nothing to
|
|
57
|
+
* confirm).
|
|
58
|
+
*/
|
|
59
|
+
owner_confirmed: z.boolean(),
|
|
60
|
+
created_at: z.iso.datetime(),
|
|
61
|
+
updated_at: z.iso.datetime(),
|
|
62
|
+
});
|
|
63
|
+
export type Connection = z.infer<typeof connectionSchema>;
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// GET /connections
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export const connectionListQuerySchema = paginationQuerySchema.extend({
|
|
70
|
+
environment_id: environmentIdSchema,
|
|
71
|
+
provider: socialProviderSchema.optional(),
|
|
72
|
+
});
|
|
73
|
+
export type ConnectionListQuery = z.infer<typeof connectionListQuerySchema>;
|
|
74
|
+
|
|
75
|
+
export const connectionListResponseSchema = cursorPageSchema(connectionSchema);
|
|
76
|
+
export type ConnectionListResponse = z.infer<typeof connectionListResponseSchema>;
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// POST /connections
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `provider_client_secret` is write-only: it is the plaintext OAuth client
|
|
84
|
+
* secret issued by the upstream provider (§20.4 "Secret暗号化" — the server
|
|
85
|
+
* encrypts it into `secret_ciphertext` before persisting). Unlike the
|
|
86
|
+
* platform-generated client/webhook/API-key secrets, this value is supplied
|
|
87
|
+
* by the caller, not generated by the server, so it never appears in any
|
|
88
|
+
* response, not even a one-time-display response.
|
|
89
|
+
*/
|
|
90
|
+
export const connectionCreateRequestSchema = z.strictObject({
|
|
91
|
+
environment_id: environmentIdSchema,
|
|
92
|
+
provider: socialProviderSchema,
|
|
93
|
+
mode: connectionModeSchema,
|
|
94
|
+
provider_key: providerKeySchema,
|
|
95
|
+
issuer: httpsUrlSchema.optional(),
|
|
96
|
+
provider_client_id: z.string().min(1),
|
|
97
|
+
provider_client_secret: z.string().min(1),
|
|
98
|
+
scopes: z.array(providerScopeSchema).max(20).default([]),
|
|
99
|
+
profile_mapping: profileMappingSchema.default({}),
|
|
100
|
+
});
|
|
101
|
+
export type ConnectionCreateRequest = z.infer<typeof connectionCreateRequestSchema>;
|
|
102
|
+
|
|
103
|
+
export const connectionCreateResponseSchema = connectionSchema;
|
|
104
|
+
export type ConnectionCreateResponse = z.infer<typeof connectionCreateResponseSchema>;
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// PATCH /connections/:connectionId
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
export const connectionUpdateRequestSchema = z.strictObject({
|
|
111
|
+
issuer: httpsUrlSchema.optional(),
|
|
112
|
+
provider_client_id: z.string().min(1).optional(),
|
|
113
|
+
/** Write-only; omit to leave the stored secret unchanged. */
|
|
114
|
+
provider_client_secret: z.string().min(1).optional(),
|
|
115
|
+
scopes: z.array(providerScopeSchema).max(20).optional(),
|
|
116
|
+
profile_mapping: profileMappingSchema.optional(),
|
|
117
|
+
status: connectionStatusSchema.optional(),
|
|
118
|
+
/** design.md §20.3: the Dashboard owner's explicit confirmation step (only meaningful for `generic_oidc`; see `connectionSchema.owner_confirmed`). */
|
|
119
|
+
owner_confirmed: z.boolean().optional(),
|
|
120
|
+
});
|
|
121
|
+
export type ConnectionUpdateRequest = z.infer<typeof connectionUpdateRequestSchema>;
|
|
122
|
+
|
|
123
|
+
export const connectionUpdateResponseSchema = connectionSchema;
|
|
124
|
+
export type ConnectionUpdateResponse = z.infer<typeof connectionUpdateResponseSchema>;
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// DELETE /connections/:connectionId
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export const connectionDeleteResponseSchema = z.strictObject({
|
|
131
|
+
id: connectionIdSchema,
|
|
132
|
+
deleted: z.literal(true),
|
|
133
|
+
});
|
|
134
|
+
export type ConnectionDeleteResponse = z.infer<typeof connectionDeleteResponseSchema>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { canonicalizeCustomHostname, domainCreateRequestSchema, domainSchema } from "./domains.js";
|
|
3
|
+
|
|
4
|
+
const VALID_DOMAIN = {
|
|
5
|
+
id: `dom_${"a".repeat(32)}`,
|
|
6
|
+
environment_id: `env_${"a".repeat(32)}`,
|
|
7
|
+
hostname: "login.customer.example",
|
|
8
|
+
status: "pending",
|
|
9
|
+
rp_enabled: true,
|
|
10
|
+
validation_errors: [],
|
|
11
|
+
created_at: "2026-08-02T08:10:00Z",
|
|
12
|
+
updated_at: "2026-08-02T08:10:00Z",
|
|
13
|
+
activated_at: null,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("domainSchema (design.md §11.9)", () => {
|
|
17
|
+
test("accepts a valid domain", () => {
|
|
18
|
+
expect(domainSchema.safeParse(VALID_DOMAIN).success).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("rejects an unknown top-level key (e.g. leaking cloudflare_custom_hostname_id)", () => {
|
|
22
|
+
expect(
|
|
23
|
+
domainSchema.safeParse({ ...VALID_DOMAIN, cloudflare_custom_hostname_id: "ch_123" }).success,
|
|
24
|
+
).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("rejects an unknown status", () => {
|
|
28
|
+
expect(domainSchema.safeParse({ ...VALID_DOMAIN, status: "unknown" }).success).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("domainCreateRequestSchema", () => {
|
|
33
|
+
test("rp_enabled defaults to false", () => {
|
|
34
|
+
const parsed = domainCreateRequestSchema.parse({
|
|
35
|
+
environment_id: VALID_DOMAIN.environment_id,
|
|
36
|
+
hostname: "login.customer.example",
|
|
37
|
+
});
|
|
38
|
+
expect(parsed.rp_enabled).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("canonicalizes hostnames explicitly at the boundary", () => {
|
|
42
|
+
const parsed = domainCreateRequestSchema.parse({
|
|
43
|
+
environment_id: VALID_DOMAIN.environment_id,
|
|
44
|
+
hostname: "LOGIN.Customer.Example",
|
|
45
|
+
});
|
|
46
|
+
expect(canonicalizeCustomHostname(parsed.hostname)).toBe("login.customer.example");
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/domains.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { cursorPageSchema, paginationQuerySchema } from "./common.js";
|
|
3
|
+
import { domainIdSchema, environmentIdSchema } from "./primitives.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `custom_domains` (design.md §11.9, §8.2). `cloudflare_custom_hostname_id`
|
|
7
|
+
* is excluded: it is an internal pointer into the Cloudflare for SaaS API
|
|
8
|
+
* (the same "internal routing" category as the assignment's named
|
|
9
|
+
* `*_database_key`/`auth_partition_id` fields), not a customer-facing
|
|
10
|
+
* attribute — validation progress is fully described by `status` and
|
|
11
|
+
* `validation_errors` without it.
|
|
12
|
+
*/
|
|
13
|
+
export const CUSTOM_DOMAIN_STATUSES = ["pending", "active", "moved", "failed", "deleting"] as const;
|
|
14
|
+
export const customDomainStatusSchema = z.enum(CUSTOM_DOMAIN_STATUSES);
|
|
15
|
+
export type CustomDomainStatus = (typeof CUSTOM_DOMAIN_STATUSES)[number];
|
|
16
|
+
|
|
17
|
+
const customHostnameSchema = z.string().min(1).max(253);
|
|
18
|
+
|
|
19
|
+
/** Canonicalize a custom hostname at an external boundary before persistence or routing. */
|
|
20
|
+
export const canonicalizeCustomHostname = (hostname: string): string => hostname.toLowerCase();
|
|
21
|
+
|
|
22
|
+
export const domainSchema = z.strictObject({
|
|
23
|
+
id: domainIdSchema,
|
|
24
|
+
environment_id: environmentIdSchema,
|
|
25
|
+
hostname: customHostnameSchema,
|
|
26
|
+
status: customDomainStatusSchema,
|
|
27
|
+
/** design.md §11.9: whether this custom domain also serves as the WebAuthn RP ID host. */
|
|
28
|
+
rp_enabled: z.boolean(),
|
|
29
|
+
validation_errors: z.array(z.string().min(1)),
|
|
30
|
+
created_at: z.iso.datetime(),
|
|
31
|
+
updated_at: z.iso.datetime(),
|
|
32
|
+
activated_at: z.iso.datetime().nullable(),
|
|
33
|
+
});
|
|
34
|
+
export type Domain = z.infer<typeof domainSchema>;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// GET /domains
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export const domainListQuerySchema = paginationQuerySchema.extend({
|
|
41
|
+
environment_id: environmentIdSchema,
|
|
42
|
+
status: customDomainStatusSchema.optional(),
|
|
43
|
+
});
|
|
44
|
+
export type DomainListQuery = z.infer<typeof domainListQuerySchema>;
|
|
45
|
+
|
|
46
|
+
export const domainListResponseSchema = cursorPageSchema(domainSchema);
|
|
47
|
+
export type DomainListResponse = z.infer<typeof domainListResponseSchema>;
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// POST /domains
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
export const domainCreateRequestSchema = z.strictObject({
|
|
53
|
+
environment_id: environmentIdSchema,
|
|
54
|
+
hostname: customHostnameSchema,
|
|
55
|
+
rp_enabled: z.boolean().default(false),
|
|
56
|
+
});
|
|
57
|
+
export type DomainCreateRequest = z.infer<typeof domainCreateRequestSchema>;
|
|
58
|
+
|
|
59
|
+
/** A freshly created domain always starts `pending` validation (design.md §11.9). */
|
|
60
|
+
export const domainCreateResponseSchema = domainSchema;
|
|
61
|
+
export type DomainCreateResponse = z.infer<typeof domainCreateResponseSchema>;
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// GET /domains/:domainId
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export const domainGetResponseSchema = domainSchema;
|
|
68
|
+
export type DomainGetResponse = z.infer<typeof domainGetResponseSchema>;
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// DELETE /domains/:domainId
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
export const domainDeleteResponseSchema = z.strictObject({
|
|
75
|
+
id: domainIdSchema,
|
|
76
|
+
deleted: z.literal(true),
|
|
77
|
+
});
|
|
78
|
+
export type DomainDeleteResponse = z.infer<typeof domainDeleteResponseSchema>;
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// PATCH /domains/:domainId
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
/**
|
|
84
|
+
* design.md §8.3: `rp_enabled` switches both the WebAuthn RP ID and the hosted
|
|
85
|
+
* Interaction Domain to this custom domain. At most one per Environment.
|
|
86
|
+
*/
|
|
87
|
+
export const domainUpdateRequestSchema = z.strictObject({
|
|
88
|
+
rp_enabled: z.boolean(),
|
|
89
|
+
});
|
|
90
|
+
export type DomainUpdateRequest = z.infer<typeof domainUpdateRequestSchema>;
|
|
91
|
+
|
|
92
|
+
export const domainUpdateResponseSchema = domainSchema;
|
|
93
|
+
export type DomainUpdateResponse = z.infer<typeof domainUpdateResponseSchema>;
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// POST /domains/:domainId/retry-validation
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
export const domainRetryValidationRequestSchema = z.strictObject({});
|
|
100
|
+
export type DomainRetryValidationRequest = z.infer<typeof domainRetryValidationRequestSchema>;
|
|
101
|
+
|
|
102
|
+
/** Retrying resets `status` to `pending` and clears prior `validation_errors`. */
|
|
103
|
+
export const domainRetryValidationResponseSchema = domainSchema;
|
|
104
|
+
export type DomainRetryValidationResponse = z.infer<typeof domainRetryValidationResponseSchema>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
environmentCreateRequestSchema,
|
|
4
|
+
environmentSchema,
|
|
5
|
+
environmentUpdateRequestSchema,
|
|
6
|
+
} from "./environments.js";
|
|
7
|
+
|
|
8
|
+
const VALID_ENVIRONMENT = {
|
|
9
|
+
id: `env_${"a".repeat(32)}`,
|
|
10
|
+
project_id: `prj_${"a".repeat(32)}`,
|
|
11
|
+
type: "production",
|
|
12
|
+
name: "Production",
|
|
13
|
+
status: "active",
|
|
14
|
+
issuer: "https://id.example-auth.com/e/env_x",
|
|
15
|
+
interaction_domain: "env-x.login.example-auth.com",
|
|
16
|
+
rp_id: "env-x.login.example-auth.com",
|
|
17
|
+
config_version: 3,
|
|
18
|
+
created_at: "2026-08-02T08:10:00Z",
|
|
19
|
+
updated_at: "2026-08-02T08:10:00Z",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("environmentSchema (design.md §11.4)", () => {
|
|
23
|
+
test("accepts a valid environment", () => {
|
|
24
|
+
expect(environmentSchema.safeParse(VALID_ENVIRONMENT).success).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("rejects an unknown top-level key (e.g. leaking auth_routing_version)", () => {
|
|
28
|
+
expect(
|
|
29
|
+
environmentSchema.safeParse({ ...VALID_ENVIRONMENT, auth_routing_version: 2 }).success,
|
|
30
|
+
).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("rejects an unknown type", () => {
|
|
34
|
+
expect(environmentSchema.safeParse({ ...VALID_ENVIRONMENT, type: "staging" }).success).toBe(
|
|
35
|
+
false,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("issuer must be https", () => {
|
|
40
|
+
expect(
|
|
41
|
+
environmentSchema.safeParse({
|
|
42
|
+
...VALID_ENVIRONMENT,
|
|
43
|
+
issuer: "http://id.example-auth.com/e/env_x",
|
|
44
|
+
}).success,
|
|
45
|
+
).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("environmentCreateRequestSchema", () => {
|
|
50
|
+
test("does not accept a caller-supplied issuer/rp_id (server-derived, design.md §8)", () => {
|
|
51
|
+
expect(
|
|
52
|
+
environmentCreateRequestSchema.safeParse({
|
|
53
|
+
project_id: VALID_ENVIRONMENT.project_id,
|
|
54
|
+
type: "test",
|
|
55
|
+
name: "Test",
|
|
56
|
+
issuer: "https://attacker.example",
|
|
57
|
+
}).success,
|
|
58
|
+
).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("environmentUpdateRequestSchema", () => {
|
|
63
|
+
test("cannot set status to deleted via PATCH (must use DELETE)", () => {
|
|
64
|
+
expect(environmentUpdateRequestSchema.safeParse({ status: "deleted" }).success).toBe(false);
|
|
65
|
+
expect(environmentUpdateRequestSchema.safeParse({ status: "suspended" }).success).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { cursorPageSchema, paginationQuerySchema } from "./common.js";
|
|
3
|
+
import { environmentIdSchema, httpsUrlSchema, projectIdSchema } from "./primitives.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `environments` (design.md §11.4, §7.3). `auth_routing_version` is excluded
|
|
7
|
+
* as internal multi-cell routing state (design.md §10.6 `AuthLocatorDO`),
|
|
8
|
+
* the same category of field the assignment's "internal routing" exclusion
|
|
9
|
+
* targets alongside the explicitly named secret columns. `config_version` is
|
|
10
|
+
* kept: it is a plain monotonic counter for the environment's own config
|
|
11
|
+
* generation (design.md §11.4 `config_version INTEGER`), not a routing
|
|
12
|
+
* detail, and is useful for dashboard cache-busting.
|
|
13
|
+
*/
|
|
14
|
+
export const ENVIRONMENT_TYPES = ["test", "production"] as const;
|
|
15
|
+
export const environmentTypeSchema = z.enum(ENVIRONMENT_TYPES);
|
|
16
|
+
export type EnvironmentType = (typeof ENVIRONMENT_TYPES)[number];
|
|
17
|
+
|
|
18
|
+
export const ENVIRONMENT_STATUSES = ["active", "suspended", "deleted"] as const;
|
|
19
|
+
export const environmentStatusSchema = z.enum(ENVIRONMENT_STATUSES);
|
|
20
|
+
export type EnvironmentStatus = (typeof ENVIRONMENT_STATUSES)[number];
|
|
21
|
+
|
|
22
|
+
export const environmentSchema = z.strictObject({
|
|
23
|
+
id: environmentIdSchema,
|
|
24
|
+
project_id: projectIdSchema,
|
|
25
|
+
type: environmentTypeSchema,
|
|
26
|
+
name: z.string().min(1).max(200),
|
|
27
|
+
status: environmentStatusSchema,
|
|
28
|
+
/** design.md §8.1: the OIDC issuer URL, distinct from the interaction domain. */
|
|
29
|
+
issuer: httpsUrlSchema,
|
|
30
|
+
/** design.md §8.1: the Hosted Login / interaction-facing hostname. */
|
|
31
|
+
interaction_domain: z.string().min(1),
|
|
32
|
+
/** design.md §8.3, §17: WebAuthn Relying Party ID for this environment's passkeys. */
|
|
33
|
+
rp_id: z.string().min(1),
|
|
34
|
+
config_version: z.number().int().nonnegative(),
|
|
35
|
+
created_at: z.iso.datetime(),
|
|
36
|
+
updated_at: z.iso.datetime(),
|
|
37
|
+
});
|
|
38
|
+
export type Environment = z.infer<typeof environmentSchema>;
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// GET /environments
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
export const environmentListQuerySchema = paginationQuerySchema.extend({
|
|
45
|
+
project_id: projectIdSchema,
|
|
46
|
+
type: environmentTypeSchema.optional(),
|
|
47
|
+
});
|
|
48
|
+
export type EnvironmentListQuery = z.infer<typeof environmentListQuerySchema>;
|
|
49
|
+
|
|
50
|
+
export const environmentListResponseSchema = cursorPageSchema(environmentSchema);
|
|
51
|
+
export type EnvironmentListResponse = z.infer<typeof environmentListResponseSchema>;
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// POST /environments
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `issuer` / `interaction_domain` / `rp_id` are NOT client-settable at
|
|
59
|
+
* creation: they are server-derived from the new environment's id and the
|
|
60
|
+
* platform's root domain (design.md §8), then fixed for the environment's
|
|
61
|
+
* lifetime (design.md §7.3's isolation guarantees depend on this).
|
|
62
|
+
*/
|
|
63
|
+
export const environmentCreateRequestSchema = z.strictObject({
|
|
64
|
+
project_id: projectIdSchema,
|
|
65
|
+
type: environmentTypeSchema,
|
|
66
|
+
name: z.string().min(1).max(200),
|
|
67
|
+
});
|
|
68
|
+
export type EnvironmentCreateRequest = z.infer<typeof environmentCreateRequestSchema>;
|
|
69
|
+
|
|
70
|
+
export const environmentCreateResponseSchema = environmentSchema;
|
|
71
|
+
export type EnvironmentCreateResponse = z.infer<typeof environmentCreateResponseSchema>;
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// GET /environments/:environmentId
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
export const environmentGetResponseSchema = environmentSchema;
|
|
78
|
+
export type EnvironmentGetResponse = z.infer<typeof environmentGetResponseSchema>;
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// PATCH /environments/:environmentId
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/** `status` here only toggles `active`/`suspended`; `deleted` is reached via `DELETE`, not `PATCH`. */
|
|
85
|
+
export const environmentUpdateRequestSchema = z.strictObject({
|
|
86
|
+
name: z.string().min(1).max(200).optional(),
|
|
87
|
+
status: z.enum(["active", "suspended"]).optional(),
|
|
88
|
+
});
|
|
89
|
+
export type EnvironmentUpdateRequest = z.infer<typeof environmentUpdateRequestSchema>;
|
|
90
|
+
|
|
91
|
+
export const environmentUpdateResponseSchema = environmentSchema;
|
|
92
|
+
export type EnvironmentUpdateResponse = z.infer<typeof environmentUpdateResponseSchema>;
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// DELETE /environments/:environmentId
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
export const environmentDeleteResponseSchema = z.strictObject({
|
|
99
|
+
id: environmentIdSchema,
|
|
100
|
+
status: z.literal("deleted"),
|
|
101
|
+
});
|
|
102
|
+
export type EnvironmentDeleteResponse = z.infer<typeof environmentDeleteResponseSchema>;
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// GET /environments/:environmentId/rp-migration
|
|
106
|
+
// POST /environments/:environmentId/rp-migration/disable-legacy
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* RP ID migration progress (design.md §8.3). The migration is in effect while
|
|
111
|
+
* the Environment has an active `rp_enabled` custom domain: `active_rp_id` is
|
|
112
|
+
* that hostname and `legacy_rp_id` the Environment's original `rp_id`;
|
|
113
|
+
* otherwise `active_rp_id` = `rp_id` and `legacy_rp_id` = null.
|
|
114
|
+
* `legacy_disabled_at` is the stored value, returned even when not in effect.
|
|
115
|
+
* User counts cover every Auth partition and only non-revoked passkeys.
|
|
116
|
+
*/
|
|
117
|
+
export const rpMigrationResponseSchema = z.strictObject({
|
|
118
|
+
environment_id: environmentIdSchema,
|
|
119
|
+
active_rp_id: z.string(),
|
|
120
|
+
legacy_rp_id: z.string().nullable(),
|
|
121
|
+
legacy_disabled_at: z.iso.datetime().nullable(),
|
|
122
|
+
total_users: z.number().int().min(0),
|
|
123
|
+
migrated_users: z.number().int().min(0),
|
|
124
|
+
legacy_only_users: z.number().int().min(0),
|
|
125
|
+
migrated_share: z.number().min(0).max(1),
|
|
126
|
+
});
|
|
127
|
+
export type RpMigrationResponse = z.infer<typeof rpMigrationResponseSchema>;
|
|
128
|
+
|
|
129
|
+
/** Irreversible: `confirm_rp_id` must equal the Environment's legacy `rp_id`. */
|
|
130
|
+
export const rpMigrationDisableLegacyRequestSchema = z.strictObject({
|
|
131
|
+
confirm_rp_id: z.string().min(1),
|
|
132
|
+
});
|
|
133
|
+
export type RpMigrationDisableLegacyRequest = z.infer<typeof rpMigrationDisableLegacyRequestSchema>;
|