@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.
Files changed (73) hide show
  1. package/LICENSE +202 -0
  2. package/dist/api-keys.d.ts +251 -0
  3. package/dist/api-keys.js +78 -0
  4. package/dist/audit-events.d.ts +94 -0
  5. package/dist/audit-events.js +45 -0
  6. package/dist/billing.d.ts +30 -0
  7. package/dist/billing.js +30 -0
  8. package/dist/clients.d.ts +328 -0
  9. package/dist/clients.js +141 -0
  10. package/dist/common.d.ts +95 -0
  11. package/dist/common.js +99 -0
  12. package/dist/connections.d.ts +201 -0
  13. package/dist/connections.js +107 -0
  14. package/dist/domains.d.ts +167 -0
  15. package/dist/domains.js +74 -0
  16. package/dist/environments.d.ts +194 -0
  17. package/dist/environments.js +101 -0
  18. package/dist/index.d.ts +27 -0
  19. package/dist/index.js +27 -0
  20. package/dist/primitives.d.ts +49 -0
  21. package/dist/primitives.js +56 -0
  22. package/dist/projects.d.ts +78 -0
  23. package/dist/projects.js +49 -0
  24. package/dist/rbac.d.ts +222 -0
  25. package/dist/rbac.js +226 -0
  26. package/dist/usage.d.ts +72 -0
  27. package/dist/usage.js +52 -0
  28. package/dist/user-jobs.d.ts +253 -0
  29. package/dist/user-jobs.js +95 -0
  30. package/dist/user-subresources.d.ts +161 -0
  31. package/dist/user-subresources.js +85 -0
  32. package/dist/users.d.ts +193 -0
  33. package/dist/users.js +112 -0
  34. package/dist/webhooks.d.ts +306 -0
  35. package/dist/webhooks.js +134 -0
  36. package/dist/workspaces.d.ts +172 -0
  37. package/dist/workspaces.js +77 -0
  38. package/package.json +42 -0
  39. package/src/api-keys.test.ts +86 -0
  40. package/src/api-keys.ts +97 -0
  41. package/src/audit-events.test.ts +44 -0
  42. package/src/audit-events.ts +57 -0
  43. package/src/billing.test.ts +57 -0
  44. package/src/billing.ts +46 -0
  45. package/src/clients.test.ts +169 -0
  46. package/src/clients.ts +182 -0
  47. package/src/common.test.ts +102 -0
  48. package/src/common.ts +127 -0
  49. package/src/connections.test.ts +65 -0
  50. package/src/connections.ts +134 -0
  51. package/src/domains.test.ts +48 -0
  52. package/src/domains.ts +104 -0
  53. package/src/environments.test.ts +67 -0
  54. package/src/environments.ts +133 -0
  55. package/src/index.ts +27 -0
  56. package/src/primitives.ts +100 -0
  57. package/src/projects.test.ts +49 -0
  58. package/src/projects.ts +72 -0
  59. package/src/rbac.test.ts +148 -0
  60. package/src/rbac.ts +250 -0
  61. package/src/secret-fields.test.ts +84 -0
  62. package/src/usage.test.ts +57 -0
  63. package/src/usage.ts +66 -0
  64. package/src/user-jobs.test.ts +86 -0
  65. package/src/user-jobs.ts +120 -0
  66. package/src/user-subresources.test.ts +115 -0
  67. package/src/user-subresources.ts +128 -0
  68. package/src/users.test.ts +91 -0
  69. package/src/users.ts +149 -0
  70. package/src/webhooks.test.ts +187 -0
  71. package/src/webhooks.ts +182 -0
  72. package/src/workspaces.test.ts +127 -0
  73. 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>;