diff --git a/adapter-manifest.json b/adapter-manifest.json index 3025d0e..8971c13 100644 --- a/adapter-manifest.json +++ b/adapter-manifest.json @@ -177,13 +177,14 @@ "issues": "session" }, { "method": "POST", "path": "/refresh", "credential": "refresh", "issues": "session" }, - { "method": "POST", "path": "/registration/phone", "credential": "access" }, + { "method": "POST", "path": "/registration/phone", "credential": "access", "delivery": true }, { "method": "POST", "path": "/registration/phone/verify", "credential": "access" }, { "method": "POST", "path": "/registration/register", "credential": "none", - "issues": "registration" + "issues": "registration", + "delivery": true }, { "method": "DELETE", "path": "/sessions", "credential": "access" }, { "method": "GET", "path": "/sessions", "credential": "access" }, diff --git a/src/lib/adapterManifest.ts b/src/lib/adapterManifest.ts index d657f7b..484855f 100644 --- a/src/lib/adapterManifest.ts +++ b/src/lib/adapterManifest.ts @@ -90,6 +90,23 @@ function successSchemas(response: ZodTypeAny | Record | unde .map(([, schema]) => schema); } +function successFields( + response: ZodTypeAny | Record | undefined, + name: string, +): ZodTypeAny[] { + return successSchemas(response) + .flatMap(unwrap) + .map((schema) => (schema as z.ZodObject).shape[name] as ZodTypeAny | undefined) + .filter((field): field is ZodTypeAny => field !== undefined); +} + +/** Whether a success response can carry an external-delivery payload. */ +export function deliveryInResponse( + response: ZodTypeAny | Record | undefined, +): boolean { + return successFields(response, 'delivery').length > 0; +} + /** * Whether a success response carries a token, and whether it always does. * @@ -100,10 +117,7 @@ function successSchemas(response: ZodTypeAny | Record | unde export function tokenInResponse( response: ZodTypeAny | Record | undefined, ): 'required' | 'optional' | 'none' { - const fields = successSchemas(response) - .flatMap(unwrap) - .map((schema) => (schema as z.ZodObject).shape.token as ZodTypeAny | undefined) - .filter((field): field is ZodTypeAny => field !== undefined); + const fields = successFields(response, 'token'); if (fields.some((field) => !(field instanceof z.ZodOptional))) { return 'required'; @@ -189,6 +203,14 @@ export function registerAdapterRoute({ ); } + // Only this direction is checked: the shared `MessageSchema` allows a delivery payload + // on routes that never send one, so its presence cannot mean a route delivers. + if (options.delivery && !deliveryInResponse(response)) { + throw new Error( + `${label} declares adapter \`delivery\` but no success response has a delivery payload.`, + ); + } + if (options.body && !options.issues) { throw new Error(`${label} declares an adapter body \`pick\` but issues nothing.`); } diff --git a/src/routes/registration.routes.ts b/src/routes/registration.routes.ts index 8e370f9..449778d 100644 --- a/src/routes/registration.routes.ts +++ b/src/routes/registration.routes.ts @@ -24,7 +24,7 @@ const registrationRouter = createRouter('/registration'); registrationRouter.post( '/register', { - adapter: { issues: 'registration' }, + adapter: { issues: 'registration', delivery: true }, summary: 'Register a new user', tags: ['Registration'], middleware: [otpIpLimiter, otpIdentityLimiter], @@ -46,6 +46,7 @@ registrationRouter.post( registrationRouter.post( '/phone', { + adapter: { delivery: true }, auth: 'access', summary: 'Register a phone number for the authenticated user', tags: ['Registration'], diff --git a/tests/unit/lib/adapterManifest.spec.ts b/tests/unit/lib/adapterManifest.spec.ts index 1b69a28..180fb9f 100644 --- a/tests/unit/lib/adapterManifest.spec.ts +++ b/tests/unit/lib/adapterManifest.spec.ts @@ -10,7 +10,11 @@ import { const Message = z.object({ message: z.string() }); const Session = z.object({ sub: z.string(), token: z.string(), ttl: z.number() }); -const MaybeToken = z.object({ message: z.string(), token: z.string().optional() }); +const MaybeToken = z.object({ + message: z.string(), + token: z.string().optional(), + delivery: z.object({ kind: z.string() }).optional(), +}); function register(overrides: Partial[0]>) { registerAdapterRoute({ @@ -115,6 +119,12 @@ describe('registerAdapterRoute', () => { ); }); + it('refuses delivery on a route whose response cannot carry a delivery payload', () => { + expect(() => register({ auth: 'access', adapter: { delivery: true } })).toThrow( + /no success response has a delivery payload/, + ); + }); + it('refuses a body pick on a route that issues nothing', () => { expect(() => register({ adapter: { body: { pick: ['message'] } } })).toThrow(/issues nothing/); }); @@ -135,6 +145,7 @@ describe('registerAdapterRoute', () => { path: '/send', auth: 'ephemeral', adapter: { credential: 'registration', delivery: true }, + response: { 200: MaybeToken }, }); expect(getAdapterManifest().routes).toEqual([ diff --git a/tests/unit/routes/adapterManifest.routes.spec.ts b/tests/unit/routes/adapterManifest.routes.spec.ts index 7f9cda1..fa8aa1a 100644 --- a/tests/unit/routes/adapterManifest.routes.spec.ts +++ b/tests/unit/routes/adapterManifest.routes.spec.ts @@ -50,7 +50,7 @@ describe('adapter manifest for the live routes', () => { // A GET is sent cross-site without a CORS preflight, so a route that sends a message // must only reach adapters as POST. - it('only exposes message-sending routes as POST', () => { + it('marks every message-sending route and exposes each as POST', () => { const delivery = manifest.routes.filter((entry) => entry.delivery); expect(delivery.map((entry) => `${entry.method} ${entry.path}`).sort()).toEqual([ @@ -59,6 +59,8 @@ describe('adapter manifest for the live routes', () => { 'POST /otp/generate-login-email-otp', 'POST /otp/generate-login-phone-otp', 'POST /otp/generate-phone-otp', + 'POST /registration/phone', + 'POST /registration/register', ]); });