@shipfox/api-auth-dto 18.0.0 → 20.0.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-auth-dto",
3
3
  "license": "MIT",
4
- "version": "18.0.0",
4
+ "version": "20.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -13,6 +13,22 @@ export function isAdminRole(value: unknown): value is AdminRole {
13
13
  const timestampSchema = z.string().datetime();
14
14
  const identifierEmailSchema = z.string().email();
15
15
  const CONTROL_OR_FORMAT_CHARACTER_RE = /[\p{Cc}\p{Cf}]/u;
16
+ const DIRECTORY_CURSOR_MAX_LENGTH = 512;
17
+ const DIRECTORY_SEARCH_MAX_LENGTH = 128;
18
+ const DIRECTORY_SEARCH_MAX_UTF16_LENGTH = DIRECTORY_SEARCH_MAX_LENGTH * 2;
19
+ const DIRECTORY_PAGE_SIZE_MAX = 100;
20
+ const DIRECTORY_LIMIT_PATTERN = /^\d+$/u;
21
+
22
+ function hasAtMostCodePoints(value: string, maxLength: number): boolean {
23
+ let length = 0;
24
+ for (const _codePoint of value) {
25
+ length += 1;
26
+ if (length > maxLength) return false;
27
+ }
28
+
29
+ return true;
30
+ }
31
+
16
32
  const reasonSchema = z
17
33
  .string()
18
34
  .min(1)
@@ -87,6 +103,82 @@ export const administratorUserSummarySchema = administratorUserIdentitySchema.ex
87
103
 
88
104
  export type AdministratorUserSummaryDto = z.infer<typeof administratorUserSummarySchema>;
89
105
 
106
+ const directoryCursorSchema = z
107
+ .string()
108
+ .min(1)
109
+ .max(DIRECTORY_CURSOR_MAX_LENGTH)
110
+ .refine((value) => !CONTROL_OR_FORMAT_CHARACTER_RE.test(value), {
111
+ message: 'must not contain control or format characters',
112
+ });
113
+
114
+ const directorySearchSchema = z
115
+ .string()
116
+ .max(DIRECTORY_SEARCH_MAX_UTF16_LENGTH)
117
+ .optional()
118
+ .transform((value) => {
119
+ if (
120
+ typeof value === 'string' &&
121
+ !CONTROL_OR_FORMAT_CHARACTER_RE.test(value) &&
122
+ value.trim().length === 0
123
+ ) {
124
+ return undefined;
125
+ }
126
+
127
+ return value;
128
+ })
129
+ .pipe(
130
+ z
131
+ .string()
132
+ .refine((value) => !CONTROL_OR_FORMAT_CHARACTER_RE.test(value), {
133
+ message: 'must not contain control or format characters',
134
+ })
135
+ .transform((value) => value.trim())
136
+ .pipe(
137
+ z
138
+ .string()
139
+ .min(1)
140
+ .refine((value) => hasAtMostCodePoints(value, DIRECTORY_SEARCH_MAX_LENGTH), {
141
+ message: `String must contain at most ${DIRECTORY_SEARCH_MAX_LENGTH} character(s)`,
142
+ }),
143
+ )
144
+ .optional(),
145
+ );
146
+
147
+ const checkedBooleanSchema = z.preprocess((value) => {
148
+ if (value === 'true') return true;
149
+ if (value === 'false') return false;
150
+ return value;
151
+ }, z.boolean());
152
+
153
+ const checkedDirectoryLimitSchema = z.preprocess((value) => {
154
+ if (typeof value === 'number' && Number.isInteger(value)) return value;
155
+ if (typeof value === 'string' && DIRECTORY_LIMIT_PATTERN.test(value)) return Number(value);
156
+ return value;
157
+ }, z.number().int().min(1).max(DIRECTORY_PAGE_SIZE_MAX));
158
+
159
+ export const administratorUserDirectoryQuerySchema = z
160
+ .object({
161
+ search: directorySearchSchema,
162
+ status: userStatusSchema.optional(),
163
+ impersonation_eligible: checkedBooleanSchema.optional(),
164
+ cursor: directoryCursorSchema.optional(),
165
+ limit: checkedDirectoryLimitSchema.default(50),
166
+ })
167
+ .strict();
168
+
169
+ export type AdministratorUserDirectoryQueryDto = z.infer<
170
+ typeof administratorUserDirectoryQuerySchema
171
+ >;
172
+
173
+ export const administratorUserDirectoryResponseSchema = z.object({
174
+ users: z.array(administratorUserSummarySchema).max(DIRECTORY_PAGE_SIZE_MAX),
175
+ next_cursor: directoryCursorSchema.nullable(),
176
+ });
177
+
178
+ export type AdministratorUserDirectoryResponseDto = z.infer<
179
+ typeof administratorUserDirectoryResponseSchema
180
+ >;
181
+
90
182
  const correlationIdSchema = z.string().min(1).max(255);
91
183
 
92
184
  export const suspendAdministratorUserBodySchema = z.object({
@@ -0,0 +1,148 @@
1
+ import {describe, expect, it} from '@shipfox/vitest/vi';
2
+ import {
3
+ administratorUserDirectoryQuerySchema,
4
+ administratorUserDirectoryResponseSchema,
5
+ } from './admin.js';
6
+
7
+ const user = {
8
+ id: '11111111-1111-4111-8111-111111111111',
9
+ email: 'alex@example.com',
10
+ name: 'Alex Shipfox',
11
+ status: 'active' as const,
12
+ email_verified_at: '2026-08-31T12:00:00.000Z',
13
+ created_at: '2026-08-01T12:00:00.000Z',
14
+ admin_role: null,
15
+ };
16
+
17
+ const parseQuery = (query: unknown) => administratorUserDirectoryQuerySchema.safeParse(query);
18
+
19
+ const parseResponse = (users: unknown[]) =>
20
+ administratorUserDirectoryResponseSchema.safeParse({users, next_cursor: null});
21
+
22
+ describe('administrator user directory query schema', () => {
23
+ it('defaults the page size and accepts every optional field', () => {
24
+ const query = administratorUserDirectoryQuerySchema.parse({
25
+ search: ' alex ',
26
+ status: 'suspended',
27
+ impersonation_eligible: 'true',
28
+ cursor: 'eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0wMSJ9',
29
+ });
30
+
31
+ expect(query).toEqual({
32
+ search: 'alex',
33
+ status: 'suspended',
34
+ impersonation_eligible: true,
35
+ cursor: 'eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0wMSJ9',
36
+ limit: 50,
37
+ });
38
+ });
39
+
40
+ it('accepts valid UUIDs as exact search values', () => {
41
+ const uuid = '22222222-2222-4222-8222-222222222222';
42
+
43
+ expect(administratorUserDirectoryQuerySchema.parse({search: uuid}).search).toBe(uuid);
44
+ expect(administratorUserDirectoryQuerySchema.parse({search: ` ${uuid} `}).search).toBe(uuid);
45
+ });
46
+
47
+ it('accepts text searches from one through 128 visible code points', () => {
48
+ expect(parseQuery({search: 'a'}).success).toBe(true);
49
+ expect(parseQuery({search: 'a'.repeat(128)}).success).toBe(true);
50
+ expect(parseQuery({search: 'a'.repeat(129)}).success).toBe(false);
51
+ expect(parseQuery({search: '😀'.repeat(128)}).success).toBe(true);
52
+ });
53
+
54
+ it('treats an empty search as omitted', () => {
55
+ expect(parseQuery({search: ''}).success).toBe(true);
56
+ });
57
+
58
+ it('rejects control and format characters in searches', () => {
59
+ for (const search of ['alex\nshipfox', 'alex\u0000shipfox', 'alex\u200Bshipfox', '\n', '\t']) {
60
+ expect(parseQuery({search})).toMatchObject({success: false});
61
+ }
62
+ });
63
+
64
+ it('keeps wildcard characters literal and trims only outer whitespace', () => {
65
+ const query = administratorUserDirectoryQuerySchema.parse({
66
+ search: ' 100%_\\ alex shipfox ',
67
+ });
68
+
69
+ expect(query.search).toBe('100%_\\ alex shipfox');
70
+ });
71
+
72
+ it('treats a blank search as omitted', () => {
73
+ expect(administratorUserDirectoryQuerySchema.parse({search: ' '})).toEqual({limit: 50});
74
+ });
75
+
76
+ it('accepts each known status and rejects unknown statuses', () => {
77
+ for (const status of ['active', 'suspended', 'deleted']) {
78
+ expect(parseQuery({status}).success).toBe(true);
79
+ }
80
+
81
+ expect(parseQuery({status: 'pending'}).success).toBe(false);
82
+ });
83
+
84
+ it('coerces only checked boolean query values', () => {
85
+ expect(
86
+ administratorUserDirectoryQuerySchema.parse({impersonation_eligible: 'true'}),
87
+ ).toMatchObject({impersonation_eligible: true});
88
+ expect(
89
+ administratorUserDirectoryQuerySchema.parse({impersonation_eligible: 'false'}),
90
+ ).toMatchObject({impersonation_eligible: false});
91
+ expect(
92
+ administratorUserDirectoryQuerySchema.parse({impersonation_eligible: true}),
93
+ ).toMatchObject({impersonation_eligible: true});
94
+
95
+ for (const value of ['1', '0', 'yes', 'TRUE', '']) {
96
+ expect(parseQuery({impersonation_eligible: value}).success).toBe(false);
97
+ }
98
+ });
99
+
100
+ it('bounds opaque cursors and rejects control or format characters', () => {
101
+ expect(parseQuery({cursor: 'a'}).success).toBe(true);
102
+ expect(parseQuery({cursor: 'not a cursor'}).success).toBe(true);
103
+ expect(parseQuery({cursor: 'a'.repeat(512)}).success).toBe(true);
104
+ expect(parseQuery({cursor: 'a'.repeat(513)}).success).toBe(false);
105
+
106
+ for (const cursor of ['cursor\nvalue', 'cursor\u0000value', 'cursor\u200Bvalue']) {
107
+ expect(parseQuery({cursor}).success).toBe(false);
108
+ }
109
+ });
110
+
111
+ it('coerces page sizes and enforces both bounds', () => {
112
+ expect(administratorUserDirectoryQuerySchema.parse({limit: '1'}).limit).toBe(1);
113
+ expect(administratorUserDirectoryQuerySchema.parse({limit: '100'}).limit).toBe(100);
114
+ expect(parseQuery({limit: '0'}).success).toBe(false);
115
+ expect(parseQuery({limit: '101'}).success).toBe(false);
116
+ expect(parseQuery({limit: '1.5'}).success).toBe(false);
117
+ expect(parseQuery({limit: true}).success).toBe(false);
118
+ expect(parseQuery({limit: 'abc'}).success).toBe(false);
119
+ expect(parseQuery({limit: '1e2'}).success).toBe(false);
120
+ });
121
+
122
+ it('rejects unknown query fields', () => {
123
+ expect(parseQuery({unexpected: 'value'}).success).toBe(false);
124
+ });
125
+ });
126
+
127
+ describe('administrator user directory response schema', () => {
128
+ it('accepts an empty response with no next page', () => {
129
+ expect(parseResponse([])).toMatchObject({success: true});
130
+ });
131
+
132
+ it('accepts a complete user summary and a nullable next cursor', () => {
133
+ const result = administratorUserDirectoryResponseSchema.parse({
134
+ users: [{...user, admin_role: 'admin-owner'}],
135
+ next_cursor: 'eyJjdXJzb3IiOiJuZXh0In0',
136
+ });
137
+
138
+ expect(result).toEqual({
139
+ users: [{...user, admin_role: 'admin-owner'}],
140
+ next_cursor: 'eyJjdXJzb3IiOiJuZXh0In0',
141
+ });
142
+ });
143
+
144
+ it('accepts 100 users and rejects a 101st user', () => {
145
+ expect(parseResponse(Array.from({length: 100}, () => user)).success).toBe(true);
146
+ expect(parseResponse(Array.from({length: 101}, () => user)).success).toBe(false);
147
+ });
148
+ });
@@ -12,6 +12,8 @@ export {
12
12
  type AdminBootstrapStateDto,
13
13
  type AdminGrantDto,
14
14
  type AdministratorGrantSummaryDto,
15
+ type AdministratorUserDirectoryQueryDto,
16
+ type AdministratorUserDirectoryResponseDto,
15
17
  type AdministratorUserIdentityDto,
16
18
  type AdministratorUserLookupQueryDto,
17
19
  type AdministratorUserMutationResponseDto,
@@ -20,6 +22,8 @@ export {
20
22
  adminBootstrapStateSchema,
21
23
  adminGrantDtoSchema,
22
24
  administratorGrantSummarySchema,
25
+ administratorUserDirectoryQuerySchema,
26
+ administratorUserDirectoryResponseSchema,
23
27
  administratorUserIdentitySchema,
24
28
  administratorUserLookupQuerySchema,
25
29
  administratorUserMutationResponseSchema,
@@ -115,6 +119,19 @@ export {
115
119
  type JobLeaseTokenClaims,
116
120
  jobLeaseTokenClaimsSchema,
117
121
  } from './job-lease-token.js';
122
+ export {
123
+ OAUTH_READ_SCOPE,
124
+ type OAuthAuthorizationServerMetadataDto,
125
+ type OAuthClientMetadataDocumentDto,
126
+ type OAuthDynamicClientRegistrationRequestDto,
127
+ type OAuthDynamicClientRegistrationResponseDto,
128
+ type OAuthProtectedResourceMetadataDto,
129
+ oauthAuthorizationServerMetadataSchema,
130
+ oauthClientMetadataDocumentSchema,
131
+ oauthDynamicClientRegistrationRequestSchema,
132
+ oauthDynamicClientRegistrationResponseSchema,
133
+ oauthProtectedResourceMetadataSchema,
134
+ } from './oauth.js';
118
135
  export {
119
136
  RUNNER_SESSION_TOKEN_AUDIENCE,
120
137
  type RunnerSessionTokenClaims,
@@ -0,0 +1,71 @@
1
+ import {describe, expect, it} from '@shipfox/vitest/vi';
2
+ import {
3
+ oauthAuthorizationServerMetadataSchema,
4
+ oauthClientMetadataDocumentSchema,
5
+ oauthDynamicClientRegistrationRequestSchema,
6
+ oauthProtectedResourceMetadataSchema,
7
+ } from './oauth.js';
8
+
9
+ describe('OAuth metadata schemas', () => {
10
+ it('accepts the MCP read-only discovery profile', () => {
11
+ expect(
12
+ oauthProtectedResourceMetadataSchema.safeParse({
13
+ resource: 'https://api.example.test/mcp',
14
+ authorization_servers: ['https://api.example.test'],
15
+ scopes_supported: ['read'],
16
+ }).success,
17
+ ).toBe(true);
18
+
19
+ expect(
20
+ oauthAuthorizationServerMetadataSchema.safeParse({
21
+ issuer: 'https://api.example.test',
22
+ authorization_endpoint: 'https://api.example.test/oauth/authorize',
23
+ token_endpoint: 'https://api.example.test/oauth/token',
24
+ registration_endpoint: 'https://api.example.test/oauth/register',
25
+ response_types_supported: ['code'],
26
+ grant_types_supported: ['authorization_code', 'refresh_token'],
27
+ code_challenge_methods_supported: ['S256'],
28
+ token_endpoint_auth_methods_supported: ['none'],
29
+ scopes_supported: ['read'],
30
+ client_id_metadata_document_supported: true,
31
+ }).success,
32
+ ).toBe(true);
33
+ });
34
+
35
+ it('requires the bounded public-client registration fields', () => {
36
+ expect(
37
+ oauthDynamicClientRegistrationRequestSchema.safeParse({
38
+ client_name: 'Desktop agent',
39
+ redirect_uris: ['http://127.0.0.1:43123/callback'],
40
+ }).success,
41
+ ).toBe(true);
42
+
43
+ expect(
44
+ oauthDynamicClientRegistrationRequestSchema.safeParse({
45
+ client_name: 'Desktop agent',
46
+ redirect_uris: [],
47
+ }).success,
48
+ ).toBe(false);
49
+ expect(
50
+ oauthDynamicClientRegistrationRequestSchema.safeParse({
51
+ client_name: 'Desktop agent',
52
+ redirect_uris: ['https://client.example/callback'],
53
+ token_endpoint_auth_method: 'client_secret_basic',
54
+ }).success,
55
+ ).toBe(false);
56
+ });
57
+
58
+ it('accepts standard optional CIMD fields without changing the identity fields', () => {
59
+ const result = oauthClientMetadataDocumentSchema.safeParse({
60
+ client_id: 'https://client.example/.well-known/oauth-client',
61
+ client_name: 'Desktop agent',
62
+ redirect_uris: ['https://client.example/callback'],
63
+ token_endpoint_auth_method: 'none',
64
+ client_uri: 'https://client.example',
65
+ contacts: ['security@client.example'],
66
+ custom_metadata: 'ignored by the profile',
67
+ });
68
+
69
+ expect(result.success).toBe(true);
70
+ });
71
+ });
@@ -0,0 +1,104 @@
1
+ import {z} from 'zod';
2
+
3
+ const oauthUrlSchema = z.string().url().max(2048);
4
+ const oauthClientNameSchema = z.string().min(1).max(256);
5
+ const oauthRedirectUriSchema = z.string().min(1).max(2048);
6
+ const oauthScopeSchema = z.string().min(1).max(256);
7
+ const oauthGrantTypeSchema = z.enum(['authorization_code', 'refresh_token']);
8
+ const oauthResponseTypeSchema = z.literal('code');
9
+
10
+ /** The only scope exposed by the MCP read-only profile. */
11
+ export const OAUTH_READ_SCOPE = 'read' as const;
12
+
13
+ /** RFC 9728 metadata advertised for the protected MCP resource. */
14
+ export const oauthProtectedResourceMetadataSchema = z
15
+ .object({
16
+ resource: oauthUrlSchema,
17
+ authorization_servers: z.array(oauthUrlSchema).min(1),
18
+ scopes_supported: z.array(z.literal(OAUTH_READ_SCOPE)).min(1),
19
+ })
20
+ .strict();
21
+
22
+ export type OAuthProtectedResourceMetadataDto = z.infer<
23
+ typeof oauthProtectedResourceMetadataSchema
24
+ >;
25
+
26
+ /** RFC 8414 metadata for the MCP authorization server. */
27
+ export const oauthAuthorizationServerMetadataSchema = z
28
+ .object({
29
+ issuer: oauthUrlSchema,
30
+ authorization_endpoint: oauthUrlSchema,
31
+ token_endpoint: oauthUrlSchema,
32
+ registration_endpoint: oauthUrlSchema,
33
+ response_types_supported: z.array(oauthResponseTypeSchema).min(1),
34
+ grant_types_supported: z.array(oauthGrantTypeSchema).min(1),
35
+ code_challenge_methods_supported: z.array(z.literal('S256')).min(1),
36
+ token_endpoint_auth_methods_supported: z.array(z.literal('none')).min(1),
37
+ scopes_supported: z.array(z.literal(OAUTH_READ_SCOPE)).min(1),
38
+ client_id_metadata_document_supported: z.literal(true),
39
+ })
40
+ .strict();
41
+
42
+ export type OAuthAuthorizationServerMetadataDto = z.infer<
43
+ typeof oauthAuthorizationServerMetadataSchema
44
+ >;
45
+
46
+ const oauthRegistrationProfileFields = {
47
+ client_name: oauthClientNameSchema,
48
+ redirect_uris: z.array(oauthRedirectUriSchema).min(1).max(10),
49
+ grant_types: z.array(oauthGrantTypeSchema).min(1).max(2).optional(),
50
+ response_types: z.array(oauthResponseTypeSchema).min(1).max(1).optional(),
51
+ token_endpoint_auth_method: z.literal('none').optional(),
52
+ scope: oauthScopeSchema.optional(),
53
+ };
54
+
55
+ /** RFC 7591 request shape accepted by the MCP public-client profile. */
56
+ export const oauthDynamicClientRegistrationRequestSchema = z
57
+ .object(oauthRegistrationProfileFields)
58
+ .strict();
59
+
60
+ export type OAuthDynamicClientRegistrationRequestDto = z.infer<
61
+ typeof oauthDynamicClientRegistrationRequestSchema
62
+ >;
63
+
64
+ /** RFC 7591 response shape returned for a newly registered public client. */
65
+ export const oauthDynamicClientRegistrationResponseSchema = z
66
+ .object({
67
+ client_id: z.string().min(1).max(2048),
68
+ client_name: oauthClientNameSchema,
69
+ redirect_uris: z.array(oauthRedirectUriSchema).min(1).max(10),
70
+ grant_types: z.array(oauthGrantTypeSchema).min(1).max(2),
71
+ response_types: z.array(oauthResponseTypeSchema).min(1).max(1),
72
+ token_endpoint_auth_method: z.literal('none'),
73
+ scope: z.literal(OAUTH_READ_SCOPE),
74
+ })
75
+ .strict();
76
+
77
+ export type OAuthDynamicClientRegistrationResponseDto = z.infer<
78
+ typeof oauthDynamicClientRegistrationResponseSchema
79
+ >;
80
+
81
+ /**
82
+ * The subset of a Client ID Metadata Document needed before authorization.
83
+ * Optional standard metadata is retained in the schema so a conforming
84
+ * document can be validated without treating unrelated fields as identity.
85
+ */
86
+ export const oauthClientMetadataDocumentSchema = z
87
+ .object({
88
+ client_id: z.string().min(1).max(2048),
89
+ client_name: oauthClientNameSchema,
90
+ redirect_uris: z.array(oauthRedirectUriSchema).min(1).max(10),
91
+ grant_types: z.array(oauthGrantTypeSchema).min(1).max(2).optional(),
92
+ response_types: z.array(oauthResponseTypeSchema).min(1).max(1).optional(),
93
+ token_endpoint_auth_method: z.string().min(1).max(128).optional(),
94
+ scope: oauthScopeSchema.optional(),
95
+ client_uri: oauthUrlSchema.optional(),
96
+ logo_uri: oauthUrlSchema.optional(),
97
+ tos_uri: oauthUrlSchema.optional(),
98
+ policy_uri: oauthUrlSchema.optional(),
99
+ jwks_uri: oauthUrlSchema.optional(),
100
+ contacts: z.array(z.string().min(1).max(256)).max(10).optional(),
101
+ })
102
+ .passthrough();
103
+
104
+ export type OAuthClientMetadataDocumentDto = z.infer<typeof oauthClientMetadataDocumentSchema>;