@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,91 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
userBlockResponseSchema,
|
|
4
|
+
userCreateRequestSchema,
|
|
5
|
+
userDeleteResponseSchema,
|
|
6
|
+
userEnvironmentQuerySchema,
|
|
7
|
+
userSchema,
|
|
8
|
+
userUpdateRequestSchema,
|
|
9
|
+
} from "./users.js";
|
|
10
|
+
|
|
11
|
+
const VALID_USER = {
|
|
12
|
+
id: `usr_${"a".repeat(32)}`,
|
|
13
|
+
environment_id: `env_${"a".repeat(32)}`,
|
|
14
|
+
email: "user@example.com",
|
|
15
|
+
email_verified: true,
|
|
16
|
+
name: "Ada Lovelace",
|
|
17
|
+
avatar_url: null,
|
|
18
|
+
locale: "en-US",
|
|
19
|
+
timezone: "UTC",
|
|
20
|
+
status: "active",
|
|
21
|
+
metadata: { plan: "pro" },
|
|
22
|
+
created_at: "2026-08-02T08:10:00Z",
|
|
23
|
+
updated_at: "2026-08-02T08:10:00Z",
|
|
24
|
+
last_login_at: null,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("userSchema (design.md §12.1-§12.2)", () => {
|
|
28
|
+
test("accepts a valid user", () => {
|
|
29
|
+
expect(userSchema.safeParse(VALID_USER).success).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("rejects an unknown top-level key (e.g. leaking security_version)", () => {
|
|
33
|
+
expect(userSchema.safeParse({ ...VALID_USER, security_version: 3 }).success).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("rejects email_ciphertext leaking through", () => {
|
|
37
|
+
expect(userSchema.safeParse({ ...VALID_USER, email_ciphertext: "abc" }).success).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("userCreateRequestSchema", () => {
|
|
42
|
+
test("requires environment_id and email", () => {
|
|
43
|
+
expect(
|
|
44
|
+
userCreateRequestSchema.safeParse({
|
|
45
|
+
environment_id: VALID_USER.environment_id,
|
|
46
|
+
email: "a@b.com",
|
|
47
|
+
}).success,
|
|
48
|
+
).toBe(true);
|
|
49
|
+
expect(
|
|
50
|
+
userCreateRequestSchema.safeParse({ environment_id: VALID_USER.environment_id }).success,
|
|
51
|
+
).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("userEnvironmentQuerySchema", () => {
|
|
56
|
+
test("requires environment_id for individual user routes", () => {
|
|
57
|
+
const environment_id = `env_${"a".repeat(32)}`;
|
|
58
|
+
expect(userEnvironmentQuerySchema.safeParse({ environment_id }).success).toBe(true);
|
|
59
|
+
expect(userEnvironmentQuerySchema.safeParse({}).success).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("userUpdateRequestSchema", () => {
|
|
64
|
+
test("does not allow patching email (dual-verify recovery flow, design.md §25)", () => {
|
|
65
|
+
expect(userUpdateRequestSchema.safeParse({ email: "new@example.com" }).success).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("accepts a partial profile patch", () => {
|
|
69
|
+
expect(userUpdateRequestSchema.safeParse({ name: "New Name" }).success).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("userDeleteResponseSchema / userBlockResponseSchema", () => {
|
|
74
|
+
test("delete only reports pending_deletion (design.md §33.1)", () => {
|
|
75
|
+
expect(
|
|
76
|
+
userDeleteResponseSchema.safeParse({ id: VALID_USER.id, status: "pending_deletion" }).success,
|
|
77
|
+
).toBe(true);
|
|
78
|
+
expect(
|
|
79
|
+
userDeleteResponseSchema.safeParse({ id: VALID_USER.id, status: "active" }).success,
|
|
80
|
+
).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("block only reports blocked", () => {
|
|
84
|
+
expect(
|
|
85
|
+
userBlockResponseSchema.safeParse({ id: VALID_USER.id, status: "blocked" }).success,
|
|
86
|
+
).toBe(true);
|
|
87
|
+
expect(userBlockResponseSchema.safeParse({ id: VALID_USER.id, status: "active" }).success).toBe(
|
|
88
|
+
false,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
package/src/users.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { cursorPageSchema, paginationQuerySchema } from "./common.js";
|
|
3
|
+
import { environmentIdSchema, userIdSchema } from "./primitives.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `users` (design.md §12.1) projected with `user_emails` (§12.2)'s decrypted
|
|
7
|
+
* display value. Excluded: `auth_partition_id` (assignment's forbidden-field
|
|
8
|
+
* list — internal Auth D1 co-location routing, §12), `security_version`
|
|
9
|
+
* (internal invalidation counter, excluded by the same rationale
|
|
10
|
+
* `@smartcrab/contracts-public`'s `/v1/me` uses), `primary_email_id`
|
|
11
|
+
* (internal FK, superseded here by the resolved `email` field itself), and
|
|
12
|
+
* `deleted_at` (status `pending_deletion` already reports this, matching
|
|
13
|
+
* `@smartcrab/contracts-public`'s `meUserSchema`).
|
|
14
|
+
*
|
|
15
|
+
* `email` is the plaintext address: `user_emails.email_ciphertext` (the
|
|
16
|
+
* forbidden column) is decrypted server-side before this projection is
|
|
17
|
+
* built, exactly as `/v1/me` does for the same underlying table.
|
|
18
|
+
* `metadata` (from `metadata_json`) IS exposed here — unlike `/v1/me`, this
|
|
19
|
+
* is the admin-facing Management API, and arbitrary app-defined user
|
|
20
|
+
* metadata is a normal, non-secret admin/dashboard field (design.md does not
|
|
21
|
+
* name it a secret column).
|
|
22
|
+
*/
|
|
23
|
+
export const USER_STATUSES = ["active", "blocked", "pending_deletion"] as const;
|
|
24
|
+
export const userStatusSchema = z.enum(USER_STATUSES);
|
|
25
|
+
export type UserStatus = (typeof USER_STATUSES)[number];
|
|
26
|
+
|
|
27
|
+
const userMetadataSchema = z.record(z.string(), z.unknown());
|
|
28
|
+
|
|
29
|
+
export const userSchema = z.strictObject({
|
|
30
|
+
id: userIdSchema,
|
|
31
|
+
environment_id: environmentIdSchema,
|
|
32
|
+
email: z.email().nullable(),
|
|
33
|
+
email_verified: z.boolean(),
|
|
34
|
+
name: z.string().nullable(),
|
|
35
|
+
avatar_url: z.url().nullable(),
|
|
36
|
+
locale: z.string().nullable(),
|
|
37
|
+
timezone: z.string().nullable(),
|
|
38
|
+
status: userStatusSchema,
|
|
39
|
+
metadata: userMetadataSchema,
|
|
40
|
+
created_at: z.iso.datetime(),
|
|
41
|
+
updated_at: z.iso.datetime(),
|
|
42
|
+
last_login_at: z.iso.datetime().nullable(),
|
|
43
|
+
});
|
|
44
|
+
export type User = z.infer<typeof userSchema>;
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// GET /users
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export const userListQuerySchema = paginationQuerySchema.extend({
|
|
51
|
+
environment_id: environmentIdSchema,
|
|
52
|
+
status: userStatusSchema.optional(),
|
|
53
|
+
/** Free-text search over name/email (matched server-side against the decrypted/normalized values). */
|
|
54
|
+
search: z.string().min(1).max(200).optional(),
|
|
55
|
+
});
|
|
56
|
+
export type UserListQuery = z.infer<typeof userListQuerySchema>;
|
|
57
|
+
|
|
58
|
+
export const userListResponseSchema = cursorPageSchema(userSchema);
|
|
59
|
+
export type UserListResponse = z.infer<typeof userListResponseSchema>;
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// POST /users
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
export const userCreateRequestSchema = z.strictObject({
|
|
66
|
+
environment_id: environmentIdSchema,
|
|
67
|
+
email: z.email(),
|
|
68
|
+
/** Admin-created users may be marked pre-verified (e.g. bulk import from a trusted source). */
|
|
69
|
+
email_verified: z.boolean().default(false),
|
|
70
|
+
name: z.string().min(1).max(200).optional(),
|
|
71
|
+
avatar_url: z.url().optional(),
|
|
72
|
+
locale: z.string().min(1).max(35).optional(),
|
|
73
|
+
timezone: z.string().min(1).max(64).optional(),
|
|
74
|
+
metadata: userMetadataSchema.default({}),
|
|
75
|
+
});
|
|
76
|
+
export type UserCreateRequest = z.infer<typeof userCreateRequestSchema>;
|
|
77
|
+
|
|
78
|
+
export const userCreateResponseSchema = userSchema;
|
|
79
|
+
export type UserCreateResponse = z.infer<typeof userCreateResponseSchema>;
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// GET /users/:userId
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/** Environment context required by every individual-user route. */
|
|
86
|
+
export const userEnvironmentQuerySchema = z.strictObject({
|
|
87
|
+
environment_id: environmentIdSchema,
|
|
88
|
+
});
|
|
89
|
+
export type UserEnvironmentQuery = z.infer<typeof userEnvironmentQuerySchema>;
|
|
90
|
+
|
|
91
|
+
export const userGetResponseSchema = userSchema;
|
|
92
|
+
export type UserGetResponse = z.infer<typeof userGetResponseSchema>;
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// PATCH /users/:userId
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `email` is deliberately excluded, same as `@smartcrab/contracts-public`'s
|
|
100
|
+
* `meUpdateRequestSchema`: changing it is a dual-verification recovery flow
|
|
101
|
+
* (design.md §25), not a generic field patch — for admin-initiated changes,
|
|
102
|
+
* design.md §25 additionally requires Owner/Admin, a reason, and an audit
|
|
103
|
+
* record, which this generic `PATCH` does not carry.
|
|
104
|
+
*/
|
|
105
|
+
export const userUpdateRequestSchema = z.strictObject({
|
|
106
|
+
name: z.string().min(1).max(200).optional(),
|
|
107
|
+
avatar_url: z.url().optional(),
|
|
108
|
+
locale: z.string().min(1).max(35).optional(),
|
|
109
|
+
timezone: z.string().min(1).max(64).optional(),
|
|
110
|
+
metadata: userMetadataSchema.optional(),
|
|
111
|
+
});
|
|
112
|
+
export type UserUpdateRequest = z.infer<typeof userUpdateRequestSchema>;
|
|
113
|
+
|
|
114
|
+
export const userUpdateResponseSchema = userSchema;
|
|
115
|
+
export type UserUpdateResponse = z.infer<typeof userUpdateResponseSchema>;
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// DELETE /users/:userId (design.md §33.1: logical delete first)
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
export const userDeleteResponseSchema = z.strictObject({
|
|
122
|
+
id: userIdSchema,
|
|
123
|
+
status: z.literal("pending_deletion"),
|
|
124
|
+
});
|
|
125
|
+
export type UserDeleteResponse = z.infer<typeof userDeleteResponseSchema>;
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// POST /users/:userId/block, /users/:userId/unblock
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
export const userBlockRequestSchema = z.strictObject({
|
|
132
|
+
reason: z.string().min(1).max(1000).optional(),
|
|
133
|
+
});
|
|
134
|
+
export type UserBlockRequest = z.infer<typeof userBlockRequestSchema>;
|
|
135
|
+
|
|
136
|
+
export const userBlockResponseSchema = z.strictObject({
|
|
137
|
+
id: userIdSchema,
|
|
138
|
+
status: z.literal("blocked"),
|
|
139
|
+
});
|
|
140
|
+
export type UserBlockResponse = z.infer<typeof userBlockResponseSchema>;
|
|
141
|
+
|
|
142
|
+
export const userUnblockRequestSchema = z.strictObject({});
|
|
143
|
+
export type UserUnblockRequest = z.infer<typeof userUnblockRequestSchema>;
|
|
144
|
+
|
|
145
|
+
export const userUnblockResponseSchema = z.strictObject({
|
|
146
|
+
id: userIdSchema,
|
|
147
|
+
status: z.literal("active"),
|
|
148
|
+
});
|
|
149
|
+
export type UserUnblockResponse = z.infer<typeof userUnblockResponseSchema>;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
webhookCreateRequestSchema,
|
|
4
|
+
webhookCreateResponseSchema,
|
|
5
|
+
webhookDeliveryListQuerySchema,
|
|
6
|
+
webhookDeliveryListResponseSchema,
|
|
7
|
+
webhookDeliverySchema,
|
|
8
|
+
webhookListResponseSchema,
|
|
9
|
+
webhookRotateSecretResponseSchema,
|
|
10
|
+
webhookSchema,
|
|
11
|
+
webhookTestResponseSchema,
|
|
12
|
+
} from "./webhooks.js";
|
|
13
|
+
|
|
14
|
+
const VALID_WEBHOOK = {
|
|
15
|
+
id: `whk_${"a".repeat(32)}`,
|
|
16
|
+
environment_id: `env_${"a".repeat(32)}`,
|
|
17
|
+
url: "https://hooks.customer.example/auth-events",
|
|
18
|
+
event_types: ["user.created", "login.succeeded"],
|
|
19
|
+
status: "active",
|
|
20
|
+
created_at: "2026-08-02T08:10:00Z",
|
|
21
|
+
updated_at: "2026-08-02T08:10:00Z",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("webhookSchema (design.md §11.13, §29.4)", () => {
|
|
25
|
+
test("accepts a valid webhook", () => {
|
|
26
|
+
expect(webhookSchema.safeParse(VALID_WEBHOOK).success).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("rejects an unknown top-level key (e.g. leaking secret_ciphertext)", () => {
|
|
30
|
+
expect(webhookSchema.safeParse({ ...VALID_WEBHOOK, secret_ciphertext: "abc" }).success).toBe(
|
|
31
|
+
false,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("rejects an unknown event type", () => {
|
|
36
|
+
expect(
|
|
37
|
+
webhookSchema.safeParse({ ...VALID_WEBHOOK, event_types: ["not.a.real.event"] }).success,
|
|
38
|
+
).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("requires at least one event type", () => {
|
|
42
|
+
expect(webhookSchema.safeParse({ ...VALID_WEBHOOK, event_types: [] }).success).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("webhookCreateRequestSchema / webhookCreateResponseSchema", () => {
|
|
47
|
+
test("create request needs environment_id, url, event_types", () => {
|
|
48
|
+
expect(
|
|
49
|
+
webhookCreateRequestSchema.safeParse({
|
|
50
|
+
environment_id: VALID_WEBHOOK.environment_id,
|
|
51
|
+
url: VALID_WEBHOOK.url,
|
|
52
|
+
event_types: ["user.created"],
|
|
53
|
+
}).success,
|
|
54
|
+
).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("create response includes a one-time plaintext secret", () => {
|
|
58
|
+
const parsed = webhookCreateResponseSchema.safeParse({
|
|
59
|
+
...VALID_WEBHOOK,
|
|
60
|
+
secret: "whsec_abc123",
|
|
61
|
+
});
|
|
62
|
+
expect(parsed.success).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("webhookListResponseSchema never includes a secret field per item", () => {
|
|
67
|
+
test("rejects an item carrying a secret in the list envelope", () => {
|
|
68
|
+
expect(
|
|
69
|
+
webhookListResponseSchema.safeParse({
|
|
70
|
+
items: [{ ...VALID_WEBHOOK, secret: "leaked" }],
|
|
71
|
+
next_cursor: null,
|
|
72
|
+
}).success,
|
|
73
|
+
).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("webhookRotateSecretResponseSchema", () => {
|
|
78
|
+
test("returns a one-time secret and rotated_at", () => {
|
|
79
|
+
expect(
|
|
80
|
+
webhookRotateSecretResponseSchema.safeParse({
|
|
81
|
+
id: VALID_WEBHOOK.id,
|
|
82
|
+
secret: "whsec_new123",
|
|
83
|
+
rotated_at: "2026-08-02T08:10:00Z",
|
|
84
|
+
}).success,
|
|
85
|
+
).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("webhookTestResponseSchema (design.md §29.4: '2xxだけ成功')", () => {
|
|
90
|
+
test("accepts a successful delivery result", () => {
|
|
91
|
+
expect(
|
|
92
|
+
webhookTestResponseSchema.safeParse({
|
|
93
|
+
delivered: true,
|
|
94
|
+
response_status: 200,
|
|
95
|
+
response_time_ms: 120,
|
|
96
|
+
requested_at: "2026-08-02T08:10:00Z",
|
|
97
|
+
}).success,
|
|
98
|
+
).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const VALID_DELIVERY = {
|
|
103
|
+
id: `dlv_${"a".repeat(32)}`,
|
|
104
|
+
webhook_id: VALID_WEBHOOK.id,
|
|
105
|
+
event_id: `evt_${"a".repeat(32)}`,
|
|
106
|
+
event_type: "login.succeeded",
|
|
107
|
+
attempt: 1,
|
|
108
|
+
status: "delivered",
|
|
109
|
+
response_status_code: 200,
|
|
110
|
+
duration_ms: 42,
|
|
111
|
+
occurred_at: "2026-08-02T08:10:00Z",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
describe("webhookDeliverySchema (design.md §29.4, §10.10)", () => {
|
|
115
|
+
test("accepts a valid delivery record", () => {
|
|
116
|
+
expect(webhookDeliverySchema.safeParse(VALID_DELIVERY).success).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("accepts a null response_status_code (e.g. a transport-level failure)", () => {
|
|
120
|
+
expect(
|
|
121
|
+
webhookDeliverySchema.safeParse({ ...VALID_DELIVERY, response_status_code: null }).success,
|
|
122
|
+
).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("rejects an unknown status value", () => {
|
|
126
|
+
expect(
|
|
127
|
+
webhookDeliverySchema.safeParse({ ...VALID_DELIVERY, status: "succeeded" }).success,
|
|
128
|
+
).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("rejects an unknown top-level key (e.g. a leaked request/response body field)", () => {
|
|
132
|
+
expect(webhookDeliverySchema.safeParse({ ...VALID_DELIVERY, body: "{}" }).success).toBe(false);
|
|
133
|
+
expect(
|
|
134
|
+
webhookDeliverySchema.safeParse({ ...VALID_DELIVERY, signature: "v1=abc" }).success,
|
|
135
|
+
).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("event_type tolerates a value outside the current webhookEventTypeSchema enum (90-day retention, design.md §10.10)", () => {
|
|
139
|
+
expect(
|
|
140
|
+
webhookDeliverySchema.safeParse({ ...VALID_DELIVERY, event_type: "some.retired.event" })
|
|
141
|
+
.success,
|
|
142
|
+
).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("webhookDeliveryListQuerySchema", () => {
|
|
147
|
+
test("accepts an empty query (no filters)", () => {
|
|
148
|
+
expect(webhookDeliveryListQuerySchema.safeParse({}).success).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("accepts status/occurred_after/occurred_before filters", () => {
|
|
152
|
+
expect(
|
|
153
|
+
webhookDeliveryListQuerySchema.safeParse({
|
|
154
|
+
status: "failed",
|
|
155
|
+
occurred_after: "2026-08-01T00:00:00Z",
|
|
156
|
+
occurred_before: "2026-08-02T00:00:00Z",
|
|
157
|
+
}).success,
|
|
158
|
+
).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("rejects an unknown status filter value", () => {
|
|
162
|
+
expect(webhookDeliveryListQuerySchema.safeParse({ status: "ok" }).success).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("rejects a limit above the shared MAX_PAGE_SIZE of 100", () => {
|
|
166
|
+
expect(webhookDeliveryListQuerySchema.safeParse({ limit: 101 }).success).toBe(false);
|
|
167
|
+
expect(webhookDeliveryListQuerySchema.safeParse({ limit: 100 }).success).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("webhookDeliveryListResponseSchema", () => {
|
|
172
|
+
test("wraps a cursor page of deliveries", () => {
|
|
173
|
+
expect(
|
|
174
|
+
webhookDeliveryListResponseSchema.safeParse({ items: [VALID_DELIVERY], next_cursor: null })
|
|
175
|
+
.success,
|
|
176
|
+
).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("rejects an item carrying a body/header/signature field", () => {
|
|
180
|
+
expect(
|
|
181
|
+
webhookDeliveryListResponseSchema.safeParse({
|
|
182
|
+
items: [{ ...VALID_DELIVERY, headers: {} }],
|
|
183
|
+
next_cursor: null,
|
|
184
|
+
}).success,
|
|
185
|
+
).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
});
|
package/src/webhooks.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { httpsUrlSchema } from "@smartcrab/contracts-public";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { cursorPageSchema, paginationQuerySchema } from "./common.js";
|
|
4
|
+
import {
|
|
5
|
+
deliveryIdSchema,
|
|
6
|
+
environmentIdSchema,
|
|
7
|
+
eventIdSchema,
|
|
8
|
+
webhookIdSchema,
|
|
9
|
+
} from "./primitives.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `webhook_endpoints` (design.md §11.13) and the Customer Webhook event
|
|
13
|
+
* catalog (design.md §29.4's "イベント例" list, treated as the canonical
|
|
14
|
+
* enum since no other source enumerates event types). `secret_ciphertext`/
|
|
15
|
+
* `secret_iv` are excluded per the assignment's forbidden-field list; like
|
|
16
|
+
* `management_api_keys` (§11.12) and client secrets, the plaintext webhook
|
|
17
|
+
* signing secret is shown exactly once, on create/rotate.
|
|
18
|
+
*/
|
|
19
|
+
export const WEBHOOK_EVENT_TYPES = [
|
|
20
|
+
"user.created",
|
|
21
|
+
"user.updated",
|
|
22
|
+
"user.deleted",
|
|
23
|
+
"user.blocked",
|
|
24
|
+
"identity.linked",
|
|
25
|
+
"identity.unlinked",
|
|
26
|
+
"passkey.created",
|
|
27
|
+
"passkey.deleted",
|
|
28
|
+
"session.created",
|
|
29
|
+
"session.revoked",
|
|
30
|
+
"login.succeeded",
|
|
31
|
+
"login.failed",
|
|
32
|
+
"email.delivery.bounced",
|
|
33
|
+
"mau.threshold_reached",
|
|
34
|
+
] as const;
|
|
35
|
+
export const webhookEventTypeSchema = z.enum(WEBHOOK_EVENT_TYPES);
|
|
36
|
+
export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number];
|
|
37
|
+
|
|
38
|
+
export const WEBHOOK_STATUSES = ["active", "disabled"] as const;
|
|
39
|
+
export const webhookStatusSchema = z.enum(WEBHOOK_STATUSES);
|
|
40
|
+
export type WebhookStatus = (typeof WEBHOOK_STATUSES)[number];
|
|
41
|
+
|
|
42
|
+
export const webhookSchema = z.strictObject({
|
|
43
|
+
id: webhookIdSchema,
|
|
44
|
+
environment_id: environmentIdSchema,
|
|
45
|
+
url: httpsUrlSchema,
|
|
46
|
+
event_types: z.array(webhookEventTypeSchema).min(1),
|
|
47
|
+
status: webhookStatusSchema,
|
|
48
|
+
created_at: z.iso.datetime(),
|
|
49
|
+
updated_at: z.iso.datetime(),
|
|
50
|
+
});
|
|
51
|
+
export type Webhook = z.infer<typeof webhookSchema>;
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// GET /webhooks
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
export const webhookListQuerySchema = paginationQuerySchema.extend({
|
|
58
|
+
environment_id: environmentIdSchema,
|
|
59
|
+
});
|
|
60
|
+
export type WebhookListQuery = z.infer<typeof webhookListQuerySchema>;
|
|
61
|
+
|
|
62
|
+
export const webhookListResponseSchema = cursorPageSchema(webhookSchema);
|
|
63
|
+
export type WebhookListResponse = z.infer<typeof webhookListResponseSchema>;
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// POST /webhooks
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export const webhookCreateRequestSchema = z.strictObject({
|
|
70
|
+
environment_id: environmentIdSchema,
|
|
71
|
+
url: httpsUrlSchema,
|
|
72
|
+
event_types: z.array(webhookEventTypeSchema).min(1),
|
|
73
|
+
});
|
|
74
|
+
export type WebhookCreateRequest = z.infer<typeof webhookCreateRequestSchema>;
|
|
75
|
+
|
|
76
|
+
/** One-time plaintext signing secret (design.md §11.12's display-once rule, applied here too; §29.4 HMAC). */
|
|
77
|
+
export const webhookCreateResponseSchema = webhookSchema.extend({
|
|
78
|
+
secret: z.string().min(1),
|
|
79
|
+
});
|
|
80
|
+
export type WebhookCreateResponse = z.infer<typeof webhookCreateResponseSchema>;
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// PATCH /webhooks/:webhookId
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
export const webhookUpdateRequestSchema = z.strictObject({
|
|
87
|
+
url: httpsUrlSchema.optional(),
|
|
88
|
+
event_types: z.array(webhookEventTypeSchema).min(1).optional(),
|
|
89
|
+
status: webhookStatusSchema.optional(),
|
|
90
|
+
});
|
|
91
|
+
export type WebhookUpdateRequest = z.infer<typeof webhookUpdateRequestSchema>;
|
|
92
|
+
|
|
93
|
+
export const webhookUpdateResponseSchema = webhookSchema;
|
|
94
|
+
export type WebhookUpdateResponse = z.infer<typeof webhookUpdateResponseSchema>;
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// DELETE /webhooks/:webhookId
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
export const webhookDeleteResponseSchema = z.strictObject({
|
|
101
|
+
id: webhookIdSchema,
|
|
102
|
+
deleted: z.literal(true),
|
|
103
|
+
});
|
|
104
|
+
export type WebhookDeleteResponse = z.infer<typeof webhookDeleteResponseSchema>;
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// POST /webhooks/:webhookId/rotate-secret
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
export const webhookRotateSecretRequestSchema = z.strictObject({});
|
|
111
|
+
export type WebhookRotateSecretRequest = z.infer<typeof webhookRotateSecretRequestSchema>;
|
|
112
|
+
|
|
113
|
+
export const webhookRotateSecretResponseSchema = z.strictObject({
|
|
114
|
+
id: webhookIdSchema,
|
|
115
|
+
secret: z.string().min(1),
|
|
116
|
+
rotated_at: z.iso.datetime(),
|
|
117
|
+
});
|
|
118
|
+
export type WebhookRotateSecretResponse = z.infer<typeof webhookRotateSecretResponseSchema>;
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// POST /webhooks/:webhookId/test
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
export const webhookTestRequestSchema = z.strictObject({
|
|
125
|
+
/** Defaults to a synthetic event if omitted; must be one of this webhook's subscribed `event_types`. */
|
|
126
|
+
event_type: webhookEventTypeSchema.optional(),
|
|
127
|
+
});
|
|
128
|
+
export type WebhookTestRequest = z.infer<typeof webhookTestRequestSchema>;
|
|
129
|
+
|
|
130
|
+
/** design.md §29.4: "2xxだけ成功". `delivered` reports whether the endpoint returned 2xx. */
|
|
131
|
+
export const webhookTestResponseSchema = z.strictObject({
|
|
132
|
+
delivered: z.boolean(),
|
|
133
|
+
response_status: z.number().int().min(100).max(599).nullable(),
|
|
134
|
+
response_time_ms: z.number().int().nonnegative().nullable(),
|
|
135
|
+
requested_at: z.iso.datetime(),
|
|
136
|
+
});
|
|
137
|
+
export type WebhookTestResponse = z.infer<typeof webhookTestResponseSchema>;
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// GET /webhooks/:webhookId/deliveries
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* `webhook_delivery_history` (design.md §29.4, §10.10): one row per delivery
|
|
145
|
+
* attempt. Deliberately excludes any request/response body, header, or
|
|
146
|
+
* signature — only status/attempt/response code/duration are ever recorded
|
|
147
|
+
* or exposed here, matching `@smartcrab/database-control`'s
|
|
148
|
+
* `WebhookDeliveryHistoryRecord`.
|
|
149
|
+
*/
|
|
150
|
+
export const WEBHOOK_DELIVERY_STATUSES = ["pending", "delivered", "failed", "exhausted"] as const;
|
|
151
|
+
export const webhookDeliveryStatusSchema = z.enum(WEBHOOK_DELIVERY_STATUSES);
|
|
152
|
+
export type WebhookDeliveryStatus = (typeof WEBHOOK_DELIVERY_STATUSES)[number];
|
|
153
|
+
|
|
154
|
+
export const webhookDeliverySchema = z.strictObject({
|
|
155
|
+
id: deliveryIdSchema,
|
|
156
|
+
webhook_id: webhookIdSchema,
|
|
157
|
+
event_id: eventIdSchema,
|
|
158
|
+
/**
|
|
159
|
+
* Not `webhookEventTypeSchema` (that closed enum belongs to a webhook's
|
|
160
|
+
* *subscription* list on `webhookSchema` above): history rows are retained
|
|
161
|
+
* 90 days (design.md §10.10) and must stay parseable even if the event
|
|
162
|
+
* catalog changes after a row was written, so this is a plain non-empty
|
|
163
|
+
* string.
|
|
164
|
+
*/
|
|
165
|
+
event_type: z.string().min(1),
|
|
166
|
+
attempt: z.number().int().positive(),
|
|
167
|
+
status: webhookDeliveryStatusSchema,
|
|
168
|
+
response_status_code: z.number().int().min(100).max(599).nullable(),
|
|
169
|
+
duration_ms: z.number().int().nonnegative(),
|
|
170
|
+
occurred_at: z.iso.datetime(),
|
|
171
|
+
});
|
|
172
|
+
export type WebhookDelivery = z.infer<typeof webhookDeliverySchema>;
|
|
173
|
+
|
|
174
|
+
export const webhookDeliveryListQuerySchema = paginationQuerySchema.extend({
|
|
175
|
+
status: webhookDeliveryStatusSchema.optional(),
|
|
176
|
+
occurred_after: z.iso.datetime().optional(),
|
|
177
|
+
occurred_before: z.iso.datetime().optional(),
|
|
178
|
+
});
|
|
179
|
+
export type WebhookDeliveryListQuery = z.infer<typeof webhookDeliveryListQuerySchema>;
|
|
180
|
+
|
|
181
|
+
export const webhookDeliveryListResponseSchema = cursorPageSchema(webhookDeliverySchema);
|
|
182
|
+
export type WebhookDeliveryListResponse = z.infer<typeof webhookDeliveryListResponseSchema>;
|