@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
@@ -0,0 +1,84 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { z } from "zod";
3
+ import * as contracts from "./index.js";
4
+
5
+ /**
6
+ * design.md §2.6/§16.2 (and this package's assignment notes): the
7
+ * Management API must never expose secret ciphertext/MAC columns or
8
+ * internal cross-shard routing identifiers. This test enumerates every
9
+ * exported zod schema in the package's public surface and asserts none of
10
+ * them can ever produce a field with one of these names, at any nesting
11
+ * depth (list item, nested object, record value, etc).
12
+ */
13
+ const FORBIDDEN_FIELD_NAMES = [
14
+ "secret_ciphertext",
15
+ "secret_iv",
16
+ "key_mac",
17
+ "encrypted_private_jwk",
18
+ "control_database_key",
19
+ "auth_database_key",
20
+ "auth_partition_id",
21
+ "email_ciphertext",
22
+ "token_mac",
23
+ ] as const;
24
+
25
+ /**
26
+ * Walks a JSON Schema document (as produced by `z.toJSONSchema`, per this
27
+ * repo's IMPL_NOTES.md §6 convention) and collects every field name that
28
+ * appears as an object's `properties` key, at any depth. This avoids
29
+ * reaching into zod's internal schema representation: `z.toJSONSchema` is
30
+ * the documented, stable way to introspect a schema's shape.
31
+ */
32
+ const collectFieldNames = (value: unknown, into: Set<string>): void => {
33
+ if (Array.isArray(value)) {
34
+ for (const item of value) collectFieldNames(item, into);
35
+ return;
36
+ }
37
+ if (typeof value !== "object" || value === null) return;
38
+ for (const [key, child] of Object.entries(value)) {
39
+ if (
40
+ key === "properties" &&
41
+ typeof child === "object" &&
42
+ child !== null &&
43
+ !Array.isArray(child)
44
+ ) {
45
+ for (const fieldName of Object.keys(child)) into.add(fieldName);
46
+ }
47
+ collectFieldNames(child, into);
48
+ }
49
+ };
50
+
51
+ const exportedSchemas: ReadonlyArray<readonly [string, z.ZodType]> = Object.entries(
52
+ contracts,
53
+ ).flatMap(([name, value]) => (value instanceof z.ZodType ? [[name, value] as const] : []));
54
+
55
+ describe("no exported schema ever exposes a forbidden secret/internal-routing field", () => {
56
+ test("at least one zod schema was actually collected (sanity check for the introspection itself)", () => {
57
+ expect(exportedSchemas.length).toBeGreaterThan(20);
58
+ });
59
+
60
+ for (const [name, schema] of exportedSchemas) {
61
+ test(`${name} does not contain any forbidden field name`, () => {
62
+ const fieldNames = new Set<string>();
63
+ collectFieldNames(z.toJSONSchema(schema), fieldNames);
64
+ const offenders = FORBIDDEN_FIELD_NAMES.filter((forbidden) => fieldNames.has(forbidden));
65
+ expect(offenders).toEqual([]);
66
+ });
67
+ }
68
+ });
69
+
70
+ describe("forbidden-field detection actually works (negative control)", () => {
71
+ test("collectFieldNames finds a forbidden field name when one is present", () => {
72
+ const canary = z.object({ id: z.string(), secret_ciphertext: z.string() });
73
+ const fieldNames = new Set<string>();
74
+ collectFieldNames(z.toJSONSchema(canary), fieldNames);
75
+ expect(fieldNames.has("secret_ciphertext")).toBe(true);
76
+ });
77
+
78
+ test("collectFieldNames finds a forbidden field name nested inside an array item", () => {
79
+ const canary = z.object({ items: z.array(z.object({ auth_partition_id: z.string() })) });
80
+ const fieldNames = new Set<string>();
81
+ collectFieldNames(z.toJSONSchema(canary), fieldNames);
82
+ expect(fieldNames.has("auth_partition_id")).toBe(true);
83
+ });
84
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { MAU_FREE_TIER_LIMIT, mauUsageResponseSchema } from "./usage.js";
3
+
4
+ const VALID_USAGE = {
5
+ workspace_id: `wsp_${"a".repeat(32)}`,
6
+ billing_month: "2026-08",
7
+ total_count: 28_500,
8
+ free_tier_limit: MAU_FREE_TIER_LIMIT,
9
+ percent_used: 95,
10
+ threshold_reached: 95,
11
+ billing_activated: false,
12
+ grace_period: {
13
+ status: "none",
14
+ started_at: null,
15
+ expires_at: null,
16
+ new_user_creation_blocked: false,
17
+ },
18
+ environments: [{ environment_id: `env_${"a".repeat(32)}`, count: 28_500 }],
19
+ };
20
+
21
+ describe("mauUsageResponseSchema (design.md §28)", () => {
22
+ test("accepts a valid usage response", () => {
23
+ expect(mauUsageResponseSchema.safeParse(VALID_USAGE).success).toBe(true);
24
+ });
25
+
26
+ test("free_tier_limit is fixed at 30,000 (design.md §28.3)", () => {
27
+ expect(
28
+ mauUsageResponseSchema.safeParse({ ...VALID_USAGE, free_tier_limit: 50_000 }).success,
29
+ ).toBe(false);
30
+ });
31
+
32
+ test("rejects an unknown threshold level", () => {
33
+ expect(
34
+ mauUsageResponseSchema.safeParse({ ...VALID_USAGE, threshold_reached: 50 }).success,
35
+ ).toBe(false);
36
+ });
37
+
38
+ test("accepts an active grace period (design.md §28.5)", () => {
39
+ expect(
40
+ mauUsageResponseSchema.safeParse({
41
+ ...VALID_USAGE,
42
+ grace_period: {
43
+ status: "active",
44
+ started_at: "2026-08-01T00:00:00Z",
45
+ expires_at: "2026-08-04T00:00:00Z",
46
+ new_user_creation_blocked: false,
47
+ },
48
+ }).success,
49
+ ).toBe(true);
50
+ });
51
+
52
+ test("rejects an unknown top-level key", () => {
53
+ expect(
54
+ mauUsageResponseSchema.safeParse({ ...VALID_USAGE, control_database_key: "x" }).success,
55
+ ).toBe(false);
56
+ });
57
+ });
package/src/usage.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ import { environmentIdSchema, workspaceIdSchema } from "./primitives.js";
3
+
4
+ /**
5
+ * `GET /usage/mau` (design.md §28: MAU課金). No D1 table is projected 1:1
6
+ * here — `mau_month` (§12.10) is a per-user ledger row, while this route
7
+ * reports the aggregated-per-workspace view design.md §28.5 describes:
8
+ * the free tier limit, threshold notifications, and grace period state.
9
+ */
10
+ export const MAU_FREE_TIER_LIMIT = 30_000;
11
+
12
+ /** design.md §28.5: "80%、95%、100%到達時にOwner/Billing roleへ通知". */
13
+ export const MAU_THRESHOLD_LEVELS = [80, 95, 100] as const;
14
+ export const mauThresholdLevelSchema = z.union([z.literal(80), z.literal(95), z.literal(100)]);
15
+ export type MauThresholdLevel = (typeof MAU_THRESHOLD_LEVELS)[number];
16
+
17
+ /** `billing_month` wire format: `YYYY-MM` (design.md §12.10 `mau_month.billing_month`). */
18
+ export const billingMonthSchema = z.string().regex(/^\d{4}-(?:0[1-9]|1[0-2])$/);
19
+
20
+ /**
21
+ * design.md §28.5: unbilled workspaces get a 72-hour grace period at 30,000
22
+ * MAU; existing users keep logging in throughout, but if billing is not
23
+ * activated before it expires, only new-user creation is blocked.
24
+ */
25
+ export const GRACE_PERIOD_STATUSES = ["none", "active", "expired"] as const;
26
+ export const gracePeriodStatusSchema = z.enum(GRACE_PERIOD_STATUSES);
27
+ export type GracePeriodStatus = (typeof GRACE_PERIOD_STATUSES)[number];
28
+
29
+ export const gracePeriodSchema = z.strictObject({
30
+ status: gracePeriodStatusSchema,
31
+ started_at: z.iso.datetime().nullable(),
32
+ /** design.md §28.5: "72時間のgrace period". */
33
+ expires_at: z.iso.datetime().nullable(),
34
+ /** design.md §28.5: "新規ユーザー作成だけを停止する" once the grace period lapses unbilled. */
35
+ new_user_creation_blocked: z.boolean(),
36
+ });
37
+ export type GracePeriod = z.infer<typeof gracePeriodSchema>;
38
+
39
+ const mauEnvironmentBreakdownSchema = z.strictObject({
40
+ environment_id: environmentIdSchema,
41
+ count: z.number().int().nonnegative(),
42
+ });
43
+ export type MauEnvironmentBreakdown = z.infer<typeof mauEnvironmentBreakdownSchema>;
44
+
45
+ export const mauUsageResponseSchema = z.strictObject({
46
+ workspace_id: workspaceIdSchema,
47
+ billing_month: billingMonthSchema,
48
+ total_count: z.number().int().nonnegative(),
49
+ free_tier_limit: z.literal(MAU_FREE_TIER_LIMIT),
50
+ percent_used: z.number().nonnegative(),
51
+ /** Highest threshold level reached so far this billing month, or `null` if none. */
52
+ threshold_reached: mauThresholdLevelSchema.nullable(),
53
+ /** design.md §28.5: whether Stripe Customer/Subscription/Payment Method are active. */
54
+ billing_activated: z.boolean(),
55
+ grace_period: gracePeriodSchema,
56
+ environments: z.array(mauEnvironmentBreakdownSchema),
57
+ });
58
+ export type MauUsageResponse = z.infer<typeof mauUsageResponseSchema>;
59
+
60
+ export const mauUsageQuerySchema = z.object({
61
+ /** Defaults server-side to the current billing month when omitted. */
62
+ billing_month: billingMonthSchema.optional(),
63
+ /** Narrows `environments` to a single environment (the top-level totals still reflect the whole workspace). */
64
+ environment_id: environmentIdSchema.optional(),
65
+ });
66
+ export type MauUsageQuery = z.infer<typeof mauUsageQuerySchema>;
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ userExportJobCreateRequestSchema,
4
+ userExportJobSchema,
5
+ userImportJobCreateRequestSchema,
6
+ userImportJobSchema,
7
+ } from "./user-jobs.js";
8
+
9
+ const ENVIRONMENT_ID = `env_${"a".repeat(32)}`;
10
+
11
+ describe("userImportJobSchema", () => {
12
+ test("accepts a valid job", () => {
13
+ expect(
14
+ userImportJobSchema.safeParse({
15
+ id: `job_${"a".repeat(32)}`,
16
+ environment_id: ENVIRONMENT_ID,
17
+ status: "processing",
18
+ format: "csv",
19
+ source_url: "https://uploads.example.com/import.csv",
20
+ total_records: 100,
21
+ processed_records: 40,
22
+ succeeded_records: 38,
23
+ failed_records: 2,
24
+ error_report_url: null,
25
+ created_by: { actor_type: "workspace_member", actor_id: `usr_${"a".repeat(32)}` },
26
+ created_at: "2026-08-02T08:10:00Z",
27
+ updated_at: "2026-08-02T08:10:00Z",
28
+ completed_at: null,
29
+ }).success,
30
+ ).toBe(true);
31
+ });
32
+
33
+ test("rejects an unknown top-level key", () => {
34
+ expect(
35
+ userImportJobCreateRequestSchema.safeParse({
36
+ environment_id: ENVIRONMENT_ID,
37
+ format: "json",
38
+ source_url: "https://uploads.example.com/import.json",
39
+ records: [{ email: "a@b.com" }],
40
+ }).success,
41
+ ).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe("userExportJobSchema (design.md §33.3)", () => {
46
+ const validExportJob = {
47
+ id: `job_${"a".repeat(32)}`,
48
+ environment_id: ENVIRONMENT_ID,
49
+ target_user_id: `usr_${"a".repeat(32)}`,
50
+ status: "completed",
51
+ contents: [
52
+ "profile",
53
+ "emails",
54
+ "linked_identities",
55
+ "passkey_metadata",
56
+ "sessions",
57
+ "consent",
58
+ "audit_events",
59
+ ],
60
+ download_url: "https://exports.example.com/user-export.json",
61
+ download_url_expires_at: "2026-08-03T08:10:00Z",
62
+ created_by: { actor_type: "api_key", actor_id: `mky_${"a".repeat(32)}` },
63
+ created_at: "2026-08-02T08:10:00Z",
64
+ updated_at: "2026-08-02T08:10:00Z",
65
+ completed_at: "2026-08-02T08:15:00Z",
66
+ };
67
+
68
+ test("accepts a valid export job with the full §33.3 content list", () => {
69
+ expect(userExportJobSchema.safeParse(validExportJob).success).toBe(true);
70
+ });
71
+
72
+ test("rejects a content value not in design.md §33.3's list (e.g. a private key)", () => {
73
+ expect(
74
+ userExportJobSchema.safeParse({ ...validExportJob, contents: ["private_key"] }).success,
75
+ ).toBe(false);
76
+ });
77
+
78
+ test("create request only needs environment_id and target_user_id", () => {
79
+ expect(
80
+ userExportJobCreateRequestSchema.safeParse({
81
+ environment_id: ENVIRONMENT_ID,
82
+ target_user_id: validExportJob.target_user_id,
83
+ }).success,
84
+ ).toBe(true);
85
+ });
86
+ });
@@ -0,0 +1,120 @@
1
+ import { httpsUrlSchema } from "@smartcrab/contracts-public";
2
+ import { z } from "zod";
3
+ import { environmentIdSchema, jobIdSchema, userIdSchema } from "./primitives.js";
4
+
5
+ /**
6
+ * `user-import-jobs` / `user-export-jobs` (design.md §26.2 route list).
7
+ *
8
+ * DEVIATION: design.md §11/§12 do not define a D1 table for either job kind
9
+ * (unlike every other resource in this package, whose field set is a direct
10
+ * projection of a named table). The shape below is derived from:
11
+ * - design.md §33.3 ("Export"): the fixed list of data an export contains
12
+ * (profile, emails, linked identities, passkey metadata, sessions,
13
+ * consent, the user's own audit events) and its explicit exclusion list
14
+ * (private key, provider token, internal risk score).
15
+ * - the async job lifecycle convention used elsewhere in the schema, e.g.
16
+ * `email_deliveries.status` (§12.8) and `outbox_events.status` (§11.16).
17
+ * - design.md §14.0's "1 row/valueは64 KB以下" (rows/values capped at 64 KB) —
18
+ * bulk import records and export output are therefore referenced by an R2
19
+ * URL, never embedded inline in the job resource itself.
20
+ *
21
+ * Should a future design.md revision add explicit
22
+ * `user_import_jobs`/`user_export_jobs` tables, this file's field set should
23
+ * be reconciled against them.
24
+ */
25
+ export const JOB_STATUSES = ["pending", "processing", "completed", "failed"] as const;
26
+ export const jobStatusSchema = z.enum(JOB_STATUSES);
27
+ export type JobStatus = (typeof JOB_STATUSES)[number];
28
+
29
+ /** Who requested the job (design.md §12.11 `audit_events.actor_type` narrowed to the kinds that can trigger a job). */
30
+ const JOB_ACTOR_TYPES = ["workspace_member", "api_key"] as const;
31
+ const jobActorSchema = z.strictObject({
32
+ actor_type: z.enum(JOB_ACTOR_TYPES),
33
+ actor_id: z.string().min(1),
34
+ });
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // POST /user-import-jobs, GET /user-import-jobs/:jobId
38
+ // ---------------------------------------------------------------------------
39
+
40
+ export const USER_IMPORT_FORMATS = ["json", "csv"] as const;
41
+ export const userImportFormatSchema = z.enum(USER_IMPORT_FORMATS);
42
+ export type UserImportFormat = (typeof USER_IMPORT_FORMATS)[number];
43
+
44
+ export const userImportJobSchema = z.strictObject({
45
+ id: jobIdSchema,
46
+ environment_id: environmentIdSchema,
47
+ status: jobStatusSchema,
48
+ format: userImportFormatSchema,
49
+ /** Where the platform fetched the source records from (an R2/customer-hosted HTTPS URL). */
50
+ source_url: httpsUrlSchema,
51
+ total_records: z.number().int().nonnegative().nullable(),
52
+ processed_records: z.number().int().nonnegative(),
53
+ succeeded_records: z.number().int().nonnegative(),
54
+ failed_records: z.number().int().nonnegative(),
55
+ /** Present once the job has processed at least one failing record; an R2-hosted per-row error report. */
56
+ error_report_url: httpsUrlSchema.nullable(),
57
+ created_by: jobActorSchema,
58
+ created_at: z.iso.datetime(),
59
+ updated_at: z.iso.datetime(),
60
+ completed_at: z.iso.datetime().nullable(),
61
+ });
62
+ export type UserImportJob = z.infer<typeof userImportJobSchema>;
63
+
64
+ export const userImportJobCreateRequestSchema = z.strictObject({
65
+ environment_id: environmentIdSchema,
66
+ format: userImportFormatSchema,
67
+ source_url: httpsUrlSchema,
68
+ });
69
+ export type UserImportJobCreateRequest = z.infer<typeof userImportJobCreateRequestSchema>;
70
+
71
+ export const userImportJobCreateResponseSchema = userImportJobSchema;
72
+ export type UserImportJobCreateResponse = z.infer<typeof userImportJobCreateResponseSchema>;
73
+
74
+ export const userImportJobGetResponseSchema = userImportJobSchema;
75
+ export type UserImportJobGetResponse = z.infer<typeof userImportJobGetResponseSchema>;
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // POST /user-export-jobs, GET /user-export-jobs/:jobId
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /** design.md §33.3's fixed export content list; not caller-configurable. */
82
+ export const USER_EXPORT_CONTENTS = [
83
+ "profile",
84
+ "emails",
85
+ "linked_identities",
86
+ "passkey_metadata",
87
+ "sessions",
88
+ "consent",
89
+ "audit_events",
90
+ ] as const;
91
+ export const userExportContentSchema = z.enum(USER_EXPORT_CONTENTS);
92
+ export type UserExportContent = (typeof USER_EXPORT_CONTENTS)[number];
93
+
94
+ export const userExportJobSchema = z.strictObject({
95
+ id: jobIdSchema,
96
+ environment_id: environmentIdSchema,
97
+ target_user_id: userIdSchema,
98
+ status: jobStatusSchema,
99
+ contents: z.array(userExportContentSchema),
100
+ /** Present once `status: "completed"`: a time-limited presigned R2 URL for the JSON export. */
101
+ download_url: httpsUrlSchema.nullable(),
102
+ download_url_expires_at: z.iso.datetime().nullable(),
103
+ created_by: jobActorSchema,
104
+ created_at: z.iso.datetime(),
105
+ updated_at: z.iso.datetime(),
106
+ completed_at: z.iso.datetime().nullable(),
107
+ });
108
+ export type UserExportJob = z.infer<typeof userExportJobSchema>;
109
+
110
+ export const userExportJobCreateRequestSchema = z.strictObject({
111
+ environment_id: environmentIdSchema,
112
+ target_user_id: userIdSchema,
113
+ });
114
+ export type UserExportJobCreateRequest = z.infer<typeof userExportJobCreateRequestSchema>;
115
+
116
+ export const userExportJobCreateResponseSchema = userExportJobSchema;
117
+ export type UserExportJobCreateResponse = z.infer<typeof userExportJobCreateResponseSchema>;
118
+
119
+ export const userExportJobGetResponseSchema = userExportJobSchema;
120
+ export type UserExportJobGetResponse = z.infer<typeof userExportJobGetResponseSchema>;
@@ -0,0 +1,115 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ userIdentityListQuerySchema,
4
+ userIdentityListResponseSchema,
5
+ userIdentitySchema,
6
+ userPasskeyListQuerySchema,
7
+ userPasskeySchema,
8
+ userSessionListQuerySchema,
9
+ userSessionSchema,
10
+ userSessionsRevokeAllResponseSchema,
11
+ } from "./user-subresources.js";
12
+
13
+ const USER_ID = `usr_${"a".repeat(32)}`;
14
+
15
+ describe("userIdentitySchema (design.md §12.3)", () => {
16
+ test("accepts a valid identity", () => {
17
+ expect(
18
+ userIdentitySchema.safeParse({
19
+ identity_id: `idn_${"a".repeat(32)}`,
20
+ user_id: USER_ID,
21
+ provider: "google",
22
+ linked_at: "2026-08-02T08:10:00Z",
23
+ last_used_at: null,
24
+ }).success,
25
+ ).toBe(true);
26
+ });
27
+
28
+ test("rejects provider_subject/profile_json/token ciphertext leaking through", () => {
29
+ expect(
30
+ userIdentitySchema.safeParse({
31
+ identity_id: `idn_${"a".repeat(32)}`,
32
+ user_id: USER_ID,
33
+ provider: "google",
34
+ linked_at: "2026-08-02T08:10:00Z",
35
+ last_used_at: null,
36
+ access_token_ciphertext: "abc",
37
+ }).success,
38
+ ).toBe(false);
39
+ });
40
+ });
41
+
42
+ describe("userPasskeySchema (design.md §12.5)", () => {
43
+ test("rejects credential_id/public_key/sign_count leaking through", () => {
44
+ const base = {
45
+ passkey_id: `psk_${"a".repeat(32)}`,
46
+ user_id: USER_ID,
47
+ name: "iPhone 15",
48
+ created_at: "2026-08-02T08:10:00Z",
49
+ last_used_at: null,
50
+ device_type: "multi_device",
51
+ backed_up: true,
52
+ };
53
+ expect(userPasskeySchema.safeParse(base).success).toBe(true);
54
+ expect(userPasskeySchema.safeParse({ ...base, sign_count: 4 }).success).toBe(false);
55
+ expect(userPasskeySchema.safeParse({ ...base, public_key: "abc" }).success).toBe(false);
56
+ });
57
+ });
58
+
59
+ describe("userSessionSchema (design.md §12.4)", () => {
60
+ test("rejects token_mac leaking through", () => {
61
+ const base = {
62
+ session_id: `ses_${"a".repeat(32)}`,
63
+ user_id: USER_ID,
64
+ client_id: `cli_${"a".repeat(32)}`,
65
+ device_name: null,
66
+ created_at: "2026-08-02T08:10:00Z",
67
+ last_active_at: "2026-08-02T08:10:00Z",
68
+ idle_expires_at: "2026-08-02T09:10:00Z",
69
+ absolute_expires_at: "2026-08-09T08:10:00Z",
70
+ revoked_at: null,
71
+ };
72
+ expect(userSessionSchema.safeParse(base).success).toBe(true);
73
+ expect(userSessionSchema.safeParse({ ...base, token_mac: "abc" }).success).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe("userIdentityListResponseSchema pagination envelope", () => {
78
+ test("wraps identities in a cursor page", () => {
79
+ expect(
80
+ userIdentityListResponseSchema.safeParse({
81
+ items: [
82
+ {
83
+ identity_id: `idn_${"a".repeat(32)}`,
84
+ user_id: USER_ID,
85
+ provider: "apple",
86
+ linked_at: "2026-08-02T08:10:00Z",
87
+ last_used_at: null,
88
+ },
89
+ ],
90
+ next_cursor: null,
91
+ }).success,
92
+ ).toBe(true);
93
+ });
94
+ });
95
+
96
+ describe("user sub-resource query schemas", () => {
97
+ test("require environment_id for every list route", () => {
98
+ const environment_id = `env_${"a".repeat(32)}`;
99
+ for (const schema of [
100
+ userIdentityListQuerySchema,
101
+ userPasskeyListQuerySchema,
102
+ userSessionListQuerySchema,
103
+ ]) {
104
+ expect(schema.safeParse({ environment_id }).success).toBe(true);
105
+ expect(schema.safeParse({}).success).toBe(false);
106
+ }
107
+ });
108
+ });
109
+
110
+ describe("userSessionsRevokeAllResponseSchema", () => {
111
+ test("only reports revoked: true", () => {
112
+ expect(userSessionsRevokeAllResponseSchema.safeParse({ revoked: true }).success).toBe(true);
113
+ expect(userSessionsRevokeAllResponseSchema.safeParse({ revoked: false }).success).toBe(false);
114
+ });
115
+ });
@@ -0,0 +1,128 @@
1
+ import { socialProviderSchema } from "@smartcrab/contracts-public";
2
+ import { z } from "zod";
3
+ import { cursorPageSchema, paginationQuerySchema } from "./common.js";
4
+ import {
5
+ clientIdSchema,
6
+ environmentIdSchema,
7
+ identityIdSchema,
8
+ passkeyIdSchema,
9
+ sessionIdSchema,
10
+ userIdSchema,
11
+ } from "./primitives.js";
12
+
13
+ /**
14
+ * Admin-facing projections of a user's sub-resources: `identities` (design.md
15
+ * §12.3), `passkeys` (§12.5), `sessions` (§12.4). Same exclusions as
16
+ * `@smartcrab/contracts-public`'s `/v1/me` sub-resource schemas
17
+ * (`provider_subject`, `profile_json`, token ciphertexts, `credential_id`,
18
+ * `public_key`, `sign_count`, `token_mac` (assignment's forbidden list),
19
+ * `ip_prefix_hash`/`user_agent_hash`), plus `user_id` added back in since
20
+ * these admin routes are not scoped to "the current session's own user" the
21
+ * way `/v1/me` is.
22
+ */
23
+
24
+ /** Environment context required by every user sub-resource route. */
25
+ export const userSubresourceQuerySchema = z.strictObject({
26
+ environment_id: environmentIdSchema,
27
+ });
28
+ export type UserSubresourceQuery = z.infer<typeof userSubresourceQuerySchema>;
29
+
30
+ const userSubresourceListQuerySchema = paginationQuerySchema.extend({
31
+ environment_id: environmentIdSchema,
32
+ });
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // GET /users/:userId/identities, DELETE /users/:userId/identities/:identityId
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export const userIdentitySchema = z.strictObject({
39
+ identity_id: identityIdSchema,
40
+ user_id: userIdSchema,
41
+ provider: socialProviderSchema,
42
+ linked_at: z.iso.datetime(),
43
+ last_used_at: z.iso.datetime().nullable(),
44
+ });
45
+ export type UserIdentity = z.infer<typeof userIdentitySchema>;
46
+
47
+ export const userIdentityListQuerySchema = userSubresourceListQuerySchema;
48
+ export type UserIdentityListQuery = z.infer<typeof userIdentityListQuerySchema>;
49
+
50
+ export const userIdentityListResponseSchema = cursorPageSchema(userIdentitySchema);
51
+ export type UserIdentityListResponse = z.infer<typeof userIdentityListResponseSchema>;
52
+
53
+ export const userIdentityDeleteResponseSchema = z.strictObject({
54
+ identity_id: identityIdSchema,
55
+ deleted: z.literal(true),
56
+ });
57
+ export type UserIdentityDeleteResponse = z.infer<typeof userIdentityDeleteResponseSchema>;
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // GET /users/:userId/passkeys, DELETE /users/:userId/passkeys/:passkeyId
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const AUTHENTICATOR_TRANSPORTS = ["ble", "hybrid", "internal", "nfc", "usb"] as const;
64
+ const authenticatorTransportSchema = z.enum(AUTHENTICATOR_TRANSPORTS);
65
+
66
+ export const PASSKEY_DEVICE_TYPES = ["single_device", "multi_device"] as const;
67
+ export const passkeyDeviceTypeSchema = z.enum(PASSKEY_DEVICE_TYPES);
68
+ export type PasskeyDeviceType = (typeof PASSKEY_DEVICE_TYPES)[number];
69
+
70
+ export const userPasskeySchema = z.strictObject({
71
+ passkey_id: passkeyIdSchema,
72
+ user_id: userIdSchema,
73
+ name: z.string().nullable(),
74
+ created_at: z.iso.datetime(),
75
+ last_used_at: z.iso.datetime().nullable(),
76
+ device_type: passkeyDeviceTypeSchema.nullable(),
77
+ backed_up: z.boolean(),
78
+ transports: z.array(authenticatorTransportSchema).optional(),
79
+ });
80
+ export type UserPasskey = z.infer<typeof userPasskeySchema>;
81
+
82
+ export const userPasskeyListQuerySchema = userSubresourceListQuerySchema;
83
+ export type UserPasskeyListQuery = z.infer<typeof userPasskeyListQuerySchema>;
84
+
85
+ export const userPasskeyListResponseSchema = cursorPageSchema(userPasskeySchema);
86
+ export type UserPasskeyListResponse = z.infer<typeof userPasskeyListResponseSchema>;
87
+
88
+ export const userPasskeyDeleteResponseSchema = z.strictObject({
89
+ passkey_id: passkeyIdSchema,
90
+ deleted: z.literal(true),
91
+ });
92
+ export type UserPasskeyDeleteResponse = z.infer<typeof userPasskeyDeleteResponseSchema>;
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // GET /users/:userId/sessions, DELETE /users/:userId/sessions/:sessionId,
96
+ // DELETE /users/:userId/sessions
97
+ // ---------------------------------------------------------------------------
98
+
99
+ export const userSessionSchema = z.strictObject({
100
+ session_id: sessionIdSchema,
101
+ user_id: userIdSchema,
102
+ client_id: clientIdSchema,
103
+ device_name: z.string().nullable(),
104
+ created_at: z.iso.datetime(),
105
+ last_active_at: z.iso.datetime(),
106
+ idle_expires_at: z.iso.datetime(),
107
+ absolute_expires_at: z.iso.datetime(),
108
+ revoked_at: z.iso.datetime().nullable(),
109
+ });
110
+ export type UserSession = z.infer<typeof userSessionSchema>;
111
+
112
+ export const userSessionListQuerySchema = userSubresourceListQuerySchema;
113
+ export type UserSessionListQuery = z.infer<typeof userSessionListQuerySchema>;
114
+
115
+ export const userSessionListResponseSchema = cursorPageSchema(userSessionSchema);
116
+ export type UserSessionListResponse = z.infer<typeof userSessionListResponseSchema>;
117
+
118
+ export const userSessionRevokeResponseSchema = z.strictObject({
119
+ session_id: sessionIdSchema,
120
+ revoked: z.literal(true),
121
+ });
122
+ export type UserSessionRevokeResponse = z.infer<typeof userSessionRevokeResponseSchema>;
123
+
124
+ /** design.md §24.3: revoking all of a user's sessions bumps `UserSecurityDO.securityVersion`. */
125
+ export const userSessionsRevokeAllResponseSchema = z.strictObject({
126
+ revoked: z.literal(true),
127
+ });
128
+ export type UserSessionsRevokeAllResponse = z.infer<typeof userSessionsRevokeAllResponseSchema>;