@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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { cursorPageSchema, paginationQuerySchema, requestIdSchema } from "./common.js";
|
|
3
|
+
import { auditEventIdSchema, environmentIdSchema, workspaceIdSchema } from "./primitives.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `audit_events` (design.md §12.11), read-only (`GET /audit-events` is the
|
|
7
|
+
* only route; design.md §12.11: "監査ログは追記専用とし、通常APIから更新・
|
|
8
|
+
* 削除できない"). `auth_partition_id` is excluded per the assignment's
|
|
9
|
+
* forbidden-field list (internal Auth D1 co-location routing).
|
|
10
|
+
* `ip_prefix_hash` is excluded for the same reason `@smartcrab/contracts-public`
|
|
11
|
+
* excludes hash-only columns from any response: an irreversible hash is not
|
|
12
|
+
* a usable value for an API consumer.
|
|
13
|
+
*/
|
|
14
|
+
export const AUDIT_ACTOR_TYPES = ["end_user", "workspace_member", "api_key", "system"] as const;
|
|
15
|
+
export const auditActorTypeSchema = z.enum(AUDIT_ACTOR_TYPES);
|
|
16
|
+
export type AuditActorType = (typeof AUDIT_ACTOR_TYPES)[number];
|
|
17
|
+
|
|
18
|
+
export const AUDIT_RESULTS = ["success", "failure"] as const;
|
|
19
|
+
export const auditResultSchema = z.enum(AUDIT_RESULTS);
|
|
20
|
+
export type AuditResult = (typeof AUDIT_RESULTS)[number];
|
|
21
|
+
|
|
22
|
+
const auditMetadataSchema = z.record(z.string(), z.unknown());
|
|
23
|
+
|
|
24
|
+
export const auditEventSchema = z.strictObject({
|
|
25
|
+
id: auditEventIdSchema,
|
|
26
|
+
environment_id: environmentIdSchema,
|
|
27
|
+
workspace_id: workspaceIdSchema,
|
|
28
|
+
actor_type: auditActorTypeSchema,
|
|
29
|
+
actor_id: z.string().min(1).nullable(),
|
|
30
|
+
action: z.string().min(1),
|
|
31
|
+
subject_type: z.string().min(1),
|
|
32
|
+
subject_id: z.string().min(1).nullable(),
|
|
33
|
+
result: auditResultSchema,
|
|
34
|
+
request_id: requestIdSchema,
|
|
35
|
+
metadata: auditMetadataSchema,
|
|
36
|
+
created_at: z.iso.datetime(),
|
|
37
|
+
});
|
|
38
|
+
export type AuditEvent = z.infer<typeof auditEventSchema>;
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// GET /audit-events
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
export const auditEventListQuerySchema = paginationQuerySchema.extend({
|
|
45
|
+
environment_id: environmentIdSchema,
|
|
46
|
+
actor_type: auditActorTypeSchema.optional(),
|
|
47
|
+
action: z.string().min(1).optional(),
|
|
48
|
+
subject_type: z.string().min(1).optional(),
|
|
49
|
+
subject_id: z.string().min(1).optional(),
|
|
50
|
+
result: auditResultSchema.optional(),
|
|
51
|
+
created_after: z.iso.datetime().optional(),
|
|
52
|
+
created_before: z.iso.datetime().optional(),
|
|
53
|
+
});
|
|
54
|
+
export type AuditEventListQuery = z.infer<typeof auditEventListQuerySchema>;
|
|
55
|
+
|
|
56
|
+
export const auditEventListResponseSchema = cursorPageSchema(auditEventSchema);
|
|
57
|
+
export type AuditEventListResponse = z.infer<typeof auditEventListResponseSchema>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
billingCheckoutSessionCreateRequestSchema,
|
|
4
|
+
billingCheckoutSessionCreateResponseSchema,
|
|
5
|
+
billingPortalSessionCreateRequestSchema,
|
|
6
|
+
billingPortalSessionCreateResponseSchema,
|
|
7
|
+
} from "./billing.js";
|
|
8
|
+
|
|
9
|
+
describe("billing checkout-session (design.md §28.5)", () => {
|
|
10
|
+
test("request requires https success/cancel URLs", () => {
|
|
11
|
+
expect(
|
|
12
|
+
billingCheckoutSessionCreateRequestSchema.safeParse({
|
|
13
|
+
success_url: "https://dashboard.example.com/billing/success",
|
|
14
|
+
cancel_url: "https://dashboard.example.com/billing/cancel",
|
|
15
|
+
}).success,
|
|
16
|
+
).toBe(true);
|
|
17
|
+
expect(
|
|
18
|
+
billingCheckoutSessionCreateRequestSchema.safeParse({
|
|
19
|
+
success_url: "http://dashboard.example.com/billing/success",
|
|
20
|
+
cancel_url: "https://dashboard.example.com/billing/cancel",
|
|
21
|
+
}).success,
|
|
22
|
+
).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("response only exposes a checkout URL, never a Stripe object id", () => {
|
|
26
|
+
const parsed = billingCheckoutSessionCreateResponseSchema.safeParse({
|
|
27
|
+
checkout_url: "https://checkout.stripe.com/c/pay/cs_test_abc",
|
|
28
|
+
expires_at: "2026-08-03T08:10:00Z",
|
|
29
|
+
});
|
|
30
|
+
expect(parsed.success).toBe(true);
|
|
31
|
+
expect(
|
|
32
|
+
billingCheckoutSessionCreateResponseSchema.safeParse({
|
|
33
|
+
checkout_url: "https://checkout.stripe.com/c/pay/cs_test_abc",
|
|
34
|
+
expires_at: "2026-08-03T08:10:00Z",
|
|
35
|
+
stripe_customer_id: "cus_123",
|
|
36
|
+
}).success,
|
|
37
|
+
).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("billing portal-session", () => {
|
|
42
|
+
test("request requires an https return_url", () => {
|
|
43
|
+
expect(
|
|
44
|
+
billingPortalSessionCreateRequestSchema.safeParse({
|
|
45
|
+
return_url: "https://dashboard.example.com/billing",
|
|
46
|
+
}).success,
|
|
47
|
+
).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("response only exposes a portal URL", () => {
|
|
51
|
+
expect(
|
|
52
|
+
billingPortalSessionCreateResponseSchema.safeParse({
|
|
53
|
+
portal_url: "https://billing.stripe.com/session/abc",
|
|
54
|
+
}).success,
|
|
55
|
+
).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/billing.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { httpsUrlSchema } from "@smartcrab/contracts-public";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `POST /billing/checkout-session`, `POST /billing/portal-session`
|
|
6
|
+
* (design.md §26.2, §28.5: "30,000 MAUを超えて継続利用するにはStripe
|
|
7
|
+
* Customer、Subscription、Payment Methodを有効化する"). design.md does not
|
|
8
|
+
* give an exact JSON shape for these two routes; the request/response
|
|
9
|
+
* fields below are the minimum Stripe Checkout/Billing Portal session
|
|
10
|
+
* creation requires (a return destination in, a redirect URL out) and
|
|
11
|
+
* intentionally surface no Stripe object IDs — this package never exposes
|
|
12
|
+
* `billing_accounts`' Stripe identifiers (design.md §11.14) through these
|
|
13
|
+
* routes, only the redirect URL the dashboard needs.
|
|
14
|
+
*/
|
|
15
|
+
export const billingCheckoutSessionCreateRequestSchema = z.strictObject({
|
|
16
|
+
/** Where Stripe Checkout redirects on success. */
|
|
17
|
+
success_url: httpsUrlSchema,
|
|
18
|
+
/** Where Stripe Checkout redirects if the workspace member cancels. */
|
|
19
|
+
cancel_url: httpsUrlSchema,
|
|
20
|
+
});
|
|
21
|
+
export type BillingCheckoutSessionCreateRequest = z.infer<
|
|
22
|
+
typeof billingCheckoutSessionCreateRequestSchema
|
|
23
|
+
>;
|
|
24
|
+
|
|
25
|
+
export const billingCheckoutSessionCreateResponseSchema = z.strictObject({
|
|
26
|
+
checkout_url: httpsUrlSchema,
|
|
27
|
+
expires_at: z.iso.datetime(),
|
|
28
|
+
});
|
|
29
|
+
export type BillingCheckoutSessionCreateResponse = z.infer<
|
|
30
|
+
typeof billingCheckoutSessionCreateResponseSchema
|
|
31
|
+
>;
|
|
32
|
+
|
|
33
|
+
export const billingPortalSessionCreateRequestSchema = z.strictObject({
|
|
34
|
+
/** Where the Stripe Billing Portal redirects when the member is done. */
|
|
35
|
+
return_url: httpsUrlSchema,
|
|
36
|
+
});
|
|
37
|
+
export type BillingPortalSessionCreateRequest = z.infer<
|
|
38
|
+
typeof billingPortalSessionCreateRequestSchema
|
|
39
|
+
>;
|
|
40
|
+
|
|
41
|
+
export const billingPortalSessionCreateResponseSchema = z.strictObject({
|
|
42
|
+
portal_url: httpsUrlSchema,
|
|
43
|
+
});
|
|
44
|
+
export type BillingPortalSessionCreateResponse = z.infer<
|
|
45
|
+
typeof billingPortalSessionCreateResponseSchema
|
|
46
|
+
>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
clientCreateRequestSchema,
|
|
4
|
+
clientCreateResponseSchema,
|
|
5
|
+
clientListResponseSchema,
|
|
6
|
+
clientSchema,
|
|
7
|
+
mobileAppSchema,
|
|
8
|
+
} from "./clients.js";
|
|
9
|
+
|
|
10
|
+
const VALID_CLIENT = {
|
|
11
|
+
id: `cli_${"a".repeat(32)}`,
|
|
12
|
+
environment_id: `env_${"a".repeat(32)}`,
|
|
13
|
+
client_type: "web_spa",
|
|
14
|
+
name: "Marketing site",
|
|
15
|
+
public_key_prefix: null,
|
|
16
|
+
redirect_uris: ["https://app.example.com/callback"],
|
|
17
|
+
post_logout_redirect_uris: ["https://app.example.com/"],
|
|
18
|
+
allowed_origins: ["https://app.example.com"],
|
|
19
|
+
allowed_scopes: ["openid", "profile", "email"],
|
|
20
|
+
allowed_resources: ["https://api.customer.example"],
|
|
21
|
+
token_endpoint_auth_method: "none",
|
|
22
|
+
require_pkce: true,
|
|
23
|
+
status: "active",
|
|
24
|
+
mobile_app: null,
|
|
25
|
+
created_at: "2026-08-02T08:10:00Z",
|
|
26
|
+
updated_at: "2026-08-02T08:10:00Z",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe("clientSchema (design.md §11.5-§11.8, §7.4)", () => {
|
|
30
|
+
test("accepts a valid web_spa client", () => {
|
|
31
|
+
expect(clientSchema.safeParse(VALID_CLIENT).success).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("accepts a react_native client with a mobile_app", () => {
|
|
35
|
+
const rnClient = {
|
|
36
|
+
...VALID_CLIENT,
|
|
37
|
+
client_type: "react_native",
|
|
38
|
+
mobile_app: {
|
|
39
|
+
app_scheme: "com.customer.app",
|
|
40
|
+
ios_bundle_id: "com.customer.app",
|
|
41
|
+
ios_team_id: "ABCDE12345",
|
|
42
|
+
android_package_name: "com.customer.app",
|
|
43
|
+
android_sha256_fingerprints: [Array(32).fill("AA").join(":")],
|
|
44
|
+
universal_link_host: "app.customer.example",
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
expect(clientSchema.safeParse(rnClient).success).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("rejects an unknown top-level key (e.g. leaking secret_hash)", () => {
|
|
51
|
+
expect(clientSchema.safeParse({ ...VALID_CLIENT, secret_hash: "abc" }).success).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("rejects an origin carrying a path", () => {
|
|
55
|
+
expect(
|
|
56
|
+
clientSchema.safeParse({ ...VALID_CLIENT, allowed_origins: ["https://app.example.com/path"] })
|
|
57
|
+
.success,
|
|
58
|
+
).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("mobileAppSchema android fingerprints (design.md §11.8)", () => {
|
|
63
|
+
const VALID_APP = {
|
|
64
|
+
app_scheme: "com.customer.app",
|
|
65
|
+
ios_bundle_id: null,
|
|
66
|
+
ios_team_id: null,
|
|
67
|
+
android_package_name: "com.customer.app",
|
|
68
|
+
android_sha256_fingerprints: [] as string[],
|
|
69
|
+
universal_link_host: null,
|
|
70
|
+
};
|
|
71
|
+
const upper = Array(32).fill("AA").join(":");
|
|
72
|
+
const lower = Array(32).fill("aa").join(":");
|
|
73
|
+
|
|
74
|
+
test.each([
|
|
75
|
+
["uppercase", upper],
|
|
76
|
+
["lowercase", lower],
|
|
77
|
+
])("accepts a %s colon-separated 32-byte fingerprint verbatim", (_label, fingerprint) => {
|
|
78
|
+
const parsed = mobileAppSchema.safeParse({
|
|
79
|
+
...VALID_APP,
|
|
80
|
+
android_sha256_fingerprints: [fingerprint],
|
|
81
|
+
});
|
|
82
|
+
expect(parsed.success).toBe(true);
|
|
83
|
+
if (parsed.success) {
|
|
84
|
+
expect(parsed.data.android_sha256_fingerprints).toEqual([fingerprint]);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test.each([
|
|
89
|
+
["no colons", "AA".repeat(32)],
|
|
90
|
+
["31 pairs", Array(31).fill("AA").join(":")],
|
|
91
|
+
["33 pairs", Array(33).fill("AA").join(":")],
|
|
92
|
+
["non-hex", Array(32).fill("ZZ").join(":")],
|
|
93
|
+
["truncated pair", `${Array(31).fill("AA").join(":")}:A`],
|
|
94
|
+
])("rejects a fingerprint with %s", (_label, fingerprint) => {
|
|
95
|
+
expect(
|
|
96
|
+
mobileAppSchema.safeParse({ ...VALID_APP, android_sha256_fingerprints: [fingerprint] })
|
|
97
|
+
.success,
|
|
98
|
+
).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("mobileAppSchema android_package_name (design.md §11.8)", () => {
|
|
103
|
+
const VALID_APP = {
|
|
104
|
+
app_scheme: "com.customer.app",
|
|
105
|
+
ios_bundle_id: null,
|
|
106
|
+
ios_team_id: null,
|
|
107
|
+
android_package_name: null as string | null,
|
|
108
|
+
android_sha256_fingerprints: [] as string[],
|
|
109
|
+
universal_link_host: null,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
test("accepts a reversed-DNS package name and null", () => {
|
|
113
|
+
expect(
|
|
114
|
+
mobileAppSchema.safeParse({ ...VALID_APP, android_package_name: "com.example.app" }).success,
|
|
115
|
+
).toBe(true);
|
|
116
|
+
expect(mobileAppSchema.safeParse(VALID_APP).success).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test.each([
|
|
120
|
+
["single segment", "notreversedns"],
|
|
121
|
+
["hyphen segment", "com.foo-bar"],
|
|
122
|
+
["empty segment", "com..foo"],
|
|
123
|
+
["leading digit", "1com.foo"],
|
|
124
|
+
["empty string", ""],
|
|
125
|
+
])("rejects a package name with %s", (_label, packageName) => {
|
|
126
|
+
expect(
|
|
127
|
+
mobileAppSchema.safeParse({ ...VALID_APP, android_package_name: packageName }).success,
|
|
128
|
+
).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("clientCreateResponseSchema", () => {
|
|
133
|
+
test("may include a one-time client_secret for a confidential client", () => {
|
|
134
|
+
const parsed = clientCreateResponseSchema.safeParse({
|
|
135
|
+
...VALID_CLIENT,
|
|
136
|
+
token_endpoint_auth_method: "client_secret_basic",
|
|
137
|
+
client_secret: "cs_live_abcdef123456",
|
|
138
|
+
});
|
|
139
|
+
expect(parsed.success).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("client_secret is optional (e.g. a public client with auth method none)", () => {
|
|
143
|
+
expect(clientCreateResponseSchema.safeParse(VALID_CLIENT).success).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("clientListResponseSchema never includes a secret field per item", () => {
|
|
148
|
+
test("rejects an item carrying client_secret in the list envelope", () => {
|
|
149
|
+
expect(
|
|
150
|
+
clientListResponseSchema.safeParse({
|
|
151
|
+
items: [{ ...VALID_CLIENT, client_secret: "leaked" }],
|
|
152
|
+
next_cursor: null,
|
|
153
|
+
}).success,
|
|
154
|
+
).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("clientCreateRequestSchema", () => {
|
|
159
|
+
test("requires environment_id, client_type, name, token_endpoint_auth_method", () => {
|
|
160
|
+
expect(
|
|
161
|
+
clientCreateRequestSchema.safeParse({
|
|
162
|
+
environment_id: VALID_CLIENT.environment_id,
|
|
163
|
+
client_type: "web_bff",
|
|
164
|
+
name: "BFF",
|
|
165
|
+
token_endpoint_auth_method: "client_secret_basic",
|
|
166
|
+
}).success,
|
|
167
|
+
).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
});
|
package/src/clients.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { cursorPageSchema, paginationQuerySchema } from "./common.js";
|
|
3
|
+
import { clientIdSchema, environmentIdSchema, httpsUrlSchema } from "./primitives.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `clients` (design.md §11.5), `client_redirect_uris` (§11.6),
|
|
7
|
+
* `client_origins` (§11.7), `mobile_apps` (§11.8) and the conceptual Client
|
|
8
|
+
* model of design.md §7.4 (which groups these tables' columns into one
|
|
9
|
+
* resource: `redirect_uris`, `post_logout_redirect_uris`, `allowed_origins`,
|
|
10
|
+
* `allowed_scopes`, `allowed_resources`).
|
|
11
|
+
*
|
|
12
|
+
* `secret_hash` (§11.5) is excluded per the assignment's secret-field rule;
|
|
13
|
+
* `public_key_prefix` is kept — it exists specifically so a non-secret
|
|
14
|
+
* fragment of client identity can be displayed (the same pattern as
|
|
15
|
+
* `management_api_keys.prefix` alongside its hidden `key_mac`, §11.12).
|
|
16
|
+
*/
|
|
17
|
+
export const CLIENT_TYPES = ["web_bff", "web_spa", "react_native"] as const;
|
|
18
|
+
export const clientTypeSchema = z.enum(CLIENT_TYPES);
|
|
19
|
+
export type ClientType = (typeof CLIENT_TYPES)[number];
|
|
20
|
+
|
|
21
|
+
export const TOKEN_ENDPOINT_AUTH_METHODS = [
|
|
22
|
+
"none",
|
|
23
|
+
"client_secret_basic",
|
|
24
|
+
"client_secret_post",
|
|
25
|
+
] as const;
|
|
26
|
+
export const tokenEndpointAuthMethodSchema = z.enum(TOKEN_ENDPOINT_AUTH_METHODS);
|
|
27
|
+
export type TokenEndpointAuthMethod = (typeof TOKEN_ENDPOINT_AUTH_METHODS)[number];
|
|
28
|
+
|
|
29
|
+
export const CLIENT_STATUSES = ["active", "disabled"] as const;
|
|
30
|
+
export const clientStatusSchema = z.enum(CLIENT_STATUSES);
|
|
31
|
+
export type ClientStatus = (typeof CLIENT_STATUSES)[number];
|
|
32
|
+
|
|
33
|
+
/** design.md §11.6: exact-match only, no wildcards; §15.9 governs shape rules by client type. */
|
|
34
|
+
const clientRedirectUriSchema = z.string().min(1).max(2048);
|
|
35
|
+
|
|
36
|
+
/** design.md §11.7: "Originにはscheme、host、portのみを許可し、path/query/fragmentを拒否する。" */
|
|
37
|
+
const originSchema = z.string().regex(/^https?:\/\/[^/?#]+$/);
|
|
38
|
+
|
|
39
|
+
/** A single non-whitespace OAuth scope token, e.g. `openid` or a custom API scope. */
|
|
40
|
+
const allowedScopeSchema = z.string().min(1).max(100).regex(/^\S+$/);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* design.md §11.8 `android_sha256_fingerprints_json`: colon-separated hex, 32
|
|
44
|
+
* bytes. Any case is accepted on the wire; the control-plane repository stores
|
|
45
|
+
* and serves the canonical uppercase form. Kept transform-free so the schema
|
|
46
|
+
* stays representable for OpenAPI generation (`z.toJSONSchema` with `io: "output"`).
|
|
47
|
+
*/
|
|
48
|
+
export const androidSha256FingerprintSchema = z
|
|
49
|
+
.string()
|
|
50
|
+
.regex(/^([0-9A-Fa-f]{2}:){31}[0-9A-Fa-f]{2}$/);
|
|
51
|
+
|
|
52
|
+
/** design.md §11.8 `android_package_name`: reversed-DNS package name with at least two dot-separated segments. */
|
|
53
|
+
export const androidPackageNameSchema = z
|
|
54
|
+
.string()
|
|
55
|
+
.regex(/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/);
|
|
56
|
+
|
|
57
|
+
/** design.md §11.8, §21.3, §27.3 "React Native設定": present only for `client_type: "react_native"`. */
|
|
58
|
+
export const mobileAppSchema = z.strictObject({
|
|
59
|
+
app_scheme: z.string().min(1),
|
|
60
|
+
ios_bundle_id: z.string().min(1).nullable(),
|
|
61
|
+
ios_team_id: z.string().min(1).nullable(),
|
|
62
|
+
android_package_name: androidPackageNameSchema.nullable(),
|
|
63
|
+
android_sha256_fingerprints: z.array(androidSha256FingerprintSchema),
|
|
64
|
+
universal_link_host: z.string().min(1).nullable(),
|
|
65
|
+
});
|
|
66
|
+
export type MobileApp = z.infer<typeof mobileAppSchema>;
|
|
67
|
+
|
|
68
|
+
export const clientSchema = z.strictObject({
|
|
69
|
+
id: clientIdSchema,
|
|
70
|
+
environment_id: environmentIdSchema,
|
|
71
|
+
client_type: clientTypeSchema,
|
|
72
|
+
name: z.string().min(1).max(200),
|
|
73
|
+
public_key_prefix: z.string().min(1).nullable(),
|
|
74
|
+
redirect_uris: z.array(clientRedirectUriSchema).max(50),
|
|
75
|
+
post_logout_redirect_uris: z.array(clientRedirectUriSchema).max(50),
|
|
76
|
+
allowed_origins: z.array(originSchema).max(50),
|
|
77
|
+
allowed_scopes: z.array(allowedScopeSchema).max(50),
|
|
78
|
+
allowed_resources: z.array(httpsUrlSchema).max(20),
|
|
79
|
+
token_endpoint_auth_method: tokenEndpointAuthMethodSchema,
|
|
80
|
+
require_pkce: z.boolean(),
|
|
81
|
+
status: clientStatusSchema,
|
|
82
|
+
mobile_app: mobileAppSchema.nullable(),
|
|
83
|
+
created_at: z.iso.datetime(),
|
|
84
|
+
updated_at: z.iso.datetime(),
|
|
85
|
+
});
|
|
86
|
+
export type Client = z.infer<typeof clientSchema>;
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// GET /clients
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
export const clientListQuerySchema = paginationQuerySchema.extend({
|
|
93
|
+
environment_id: environmentIdSchema,
|
|
94
|
+
client_type: clientTypeSchema.optional(),
|
|
95
|
+
});
|
|
96
|
+
export type ClientListQuery = z.infer<typeof clientListQuerySchema>;
|
|
97
|
+
|
|
98
|
+
export const clientListResponseSchema = cursorPageSchema(clientSchema);
|
|
99
|
+
export type ClientListResponse = z.infer<typeof clientListResponseSchema>;
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// POST /clients
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
export const clientCreateRequestSchema = z.strictObject({
|
|
106
|
+
environment_id: environmentIdSchema,
|
|
107
|
+
client_type: clientTypeSchema,
|
|
108
|
+
name: z.string().min(1).max(200),
|
|
109
|
+
redirect_uris: z.array(clientRedirectUriSchema).max(50).default([]),
|
|
110
|
+
post_logout_redirect_uris: z.array(clientRedirectUriSchema).max(50).default([]),
|
|
111
|
+
allowed_origins: z.array(originSchema).max(50).default([]),
|
|
112
|
+
allowed_scopes: z.array(allowedScopeSchema).max(50).default([]),
|
|
113
|
+
allowed_resources: z.array(httpsUrlSchema).max(20).default([]),
|
|
114
|
+
token_endpoint_auth_method: tokenEndpointAuthMethodSchema,
|
|
115
|
+
require_pkce: z.boolean().default(true),
|
|
116
|
+
mobile_app: mobileAppSchema.optional(),
|
|
117
|
+
});
|
|
118
|
+
export type ClientCreateRequest = z.infer<typeof clientCreateRequestSchema>;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* design.md §11.12's "Secretは作成時に1回だけ表示する" applies by the same
|
|
122
|
+
* logic to any generated confidential-client secret: `client_secret` is
|
|
123
|
+
* present only when `token_endpoint_auth_method !== "none"`, and only on this
|
|
124
|
+
* one response — never on `GET`/`PATCH`/list.
|
|
125
|
+
*/
|
|
126
|
+
export const clientCreateResponseSchema = clientSchema.extend({
|
|
127
|
+
client_secret: z.string().min(1).optional(),
|
|
128
|
+
});
|
|
129
|
+
export type ClientCreateResponse = z.infer<typeof clientCreateResponseSchema>;
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// GET /clients/:clientId
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
export const clientGetResponseSchema = clientSchema;
|
|
136
|
+
export type ClientGetResponse = z.infer<typeof clientGetResponseSchema>;
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// PATCH /clients/:clientId
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
export const clientUpdateRequestSchema = z.strictObject({
|
|
143
|
+
name: z.string().min(1).max(200).optional(),
|
|
144
|
+
redirect_uris: z.array(clientRedirectUriSchema).max(50).optional(),
|
|
145
|
+
post_logout_redirect_uris: z.array(clientRedirectUriSchema).max(50).optional(),
|
|
146
|
+
allowed_origins: z.array(originSchema).max(50).optional(),
|
|
147
|
+
allowed_scopes: z.array(allowedScopeSchema).max(50).optional(),
|
|
148
|
+
allowed_resources: z.array(httpsUrlSchema).max(20).optional(),
|
|
149
|
+
token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(),
|
|
150
|
+
require_pkce: z.boolean().optional(),
|
|
151
|
+
status: clientStatusSchema.optional(),
|
|
152
|
+
mobile_app: mobileAppSchema.nullable().optional(),
|
|
153
|
+
});
|
|
154
|
+
export type ClientUpdateRequest = z.infer<typeof clientUpdateRequestSchema>;
|
|
155
|
+
|
|
156
|
+
export const clientUpdateResponseSchema = clientSchema;
|
|
157
|
+
export type ClientUpdateResponse = z.infer<typeof clientUpdateResponseSchema>;
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// DELETE /clients/:clientId
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export const clientDeleteResponseSchema = z.strictObject({
|
|
164
|
+
id: clientIdSchema,
|
|
165
|
+
deleted: z.literal(true),
|
|
166
|
+
});
|
|
167
|
+
export type ClientDeleteResponse = z.infer<typeof clientDeleteResponseSchema>;
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// POST /clients/:clientId/rotate-secret
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
export const clientRotateSecretRequestSchema = z.strictObject({});
|
|
174
|
+
export type ClientRotateSecretRequest = z.infer<typeof clientRotateSecretRequestSchema>;
|
|
175
|
+
|
|
176
|
+
/** One-time plaintext secret (design.md §11.12's display-once rule, applied to client secrets). */
|
|
177
|
+
export const clientRotateSecretResponseSchema = z.strictObject({
|
|
178
|
+
id: clientIdSchema,
|
|
179
|
+
client_secret: z.string().min(1),
|
|
180
|
+
rotated_at: z.iso.datetime(),
|
|
181
|
+
});
|
|
182
|
+
export type ClientRotateSecretResponse = z.infer<typeof clientRotateSecretResponseSchema>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
cursorPageSchema,
|
|
4
|
+
idempotencyKeyHeaderSchema,
|
|
5
|
+
ifMatchHeaderSchema,
|
|
6
|
+
paginationQuerySchema,
|
|
7
|
+
problemDetailsSchema,
|
|
8
|
+
requestIdHeaderSchema,
|
|
9
|
+
} from "./common.js";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
describe("paginationQuerySchema (design.md §26.3)", () => {
|
|
13
|
+
test("defaults limit to 25 when omitted", () => {
|
|
14
|
+
const parsed = paginationQuerySchema.parse({});
|
|
15
|
+
expect(parsed.limit).toBe(25);
|
|
16
|
+
expect(parsed.cursor).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("accepts a cursor and an in-range limit", () => {
|
|
20
|
+
const parsed = paginationQuerySchema.parse({ cursor: "abc", limit: "50" });
|
|
21
|
+
expect(parsed).toEqual({ cursor: "abc", limit: 50 });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("rejects a limit above the max page size of 100", () => {
|
|
25
|
+
expect(paginationQuerySchema.safeParse({ limit: 101 }).success).toBe(false);
|
|
26
|
+
expect(paginationQuerySchema.safeParse({ limit: 100 }).success).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("rejects a non-positive limit", () => {
|
|
30
|
+
expect(paginationQuerySchema.safeParse({ limit: 0 }).success).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("cursorPageSchema", () => {
|
|
35
|
+
const itemSchema = z.strictObject({ name: z.string() });
|
|
36
|
+
const pageSchema = cursorPageSchema(itemSchema);
|
|
37
|
+
|
|
38
|
+
test("accepts items plus a nullable next_cursor", () => {
|
|
39
|
+
expect(pageSchema.safeParse({ items: [{ name: "a" }], next_cursor: null }).success).toBe(true);
|
|
40
|
+
expect(pageSchema.safeParse({ items: [], next_cursor: "cur_abc" }).success).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("rejects an unknown top-level key", () => {
|
|
44
|
+
expect(pageSchema.safeParse({ items: [], next_cursor: null, total: 1 }).success).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("rejects a missing next_cursor", () => {
|
|
48
|
+
expect(pageSchema.safeParse({ items: [] }).success).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("idempotencyKeyHeaderSchema / ifMatchHeaderSchema (design.md §26.3)", () => {
|
|
53
|
+
test("idempotency key header requires a non-empty value", () => {
|
|
54
|
+
expect(idempotencyKeyHeaderSchema.safeParse({ "idempotency-key": "key-123" }).success).toBe(
|
|
55
|
+
true,
|
|
56
|
+
);
|
|
57
|
+
expect(idempotencyKeyHeaderSchema.safeParse({ "idempotency-key": "" }).success).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("if-match header requires a non-empty ETag value", () => {
|
|
61
|
+
expect(ifMatchHeaderSchema.safeParse({ "if-match": '"v1"' }).success).toBe(true);
|
|
62
|
+
expect(ifMatchHeaderSchema.safeParse({ "if-match": "" }).success).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("requestIdHeaderSchema", () => {
|
|
67
|
+
test("requires the req_ prefix and 32 base32url chars", () => {
|
|
68
|
+
expect(
|
|
69
|
+
requestIdHeaderSchema.safeParse({ "x-request-id": `req_${"a".repeat(32)}` }).success,
|
|
70
|
+
).toBe(true);
|
|
71
|
+
expect(requestIdHeaderSchema.safeParse({ "x-request-id": "req_short" }).success).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("problemDetailsSchema (design.md §26.3 example)", () => {
|
|
76
|
+
test("accepts the exact example from design.md", () => {
|
|
77
|
+
const parsed = problemDetailsSchema.safeParse({
|
|
78
|
+
type: "https://docs.example-auth.com/errors/invalid-redirect-uri",
|
|
79
|
+
title: "Invalid redirect URI",
|
|
80
|
+
status: 400,
|
|
81
|
+
detail: "The redirect URI is not registered for this client.",
|
|
82
|
+
code: "invalid_redirect_uri",
|
|
83
|
+
request_id: `req_${"a".repeat(32)}`,
|
|
84
|
+
});
|
|
85
|
+
expect(parsed.success).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("allows RFC 9457 extension members alongside the standard fields", () => {
|
|
89
|
+
expect(
|
|
90
|
+
problemDetailsSchema.safeParse({
|
|
91
|
+
type: "https://docs.example-auth.com/errors/conflict",
|
|
92
|
+
title: "Conflict",
|
|
93
|
+
status: 409,
|
|
94
|
+
current_etag: '"v2"',
|
|
95
|
+
}).success,
|
|
96
|
+
).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("requires type/title/status", () => {
|
|
100
|
+
expect(problemDetailsSchema.safeParse({ title: "x", status: 400 }).success).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
});
|