Skip to content

Commit 8d191da

Browse files
committed
Teach codegen to consume adapter packages: serverPackage entries, setupFn, and manifest routes
1 parent 32ca2f6 commit 8d191da

18 files changed

Lines changed: 297 additions & 21 deletions

File tree

waspc/data/Generator/libs/auth-contract/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
The contract between Wasp and pluggable auth providers.
44

5-
An auth provider implements this contract to make an external auth solution
6-
(Better Auth, Clerk, WorkOS, ...) usable as a Wasp auth provider: `AuthProvider`,
7-
`VerifiedSession`, and the optional `SessionIssuingAuthProvider` capability.
5+
An auth adapter package implements this contract to make an external auth
6+
solution (Better Auth, Clerk, WorkOS, ...) usable as a Wasp auth provider:
7+
`AuthProvider`, `VerifiedSession`, `WaspServerRuntime`, and the
8+
`createServerAdapter` factory shape adapter packages must export.
89

910
The package is copied into generated Wasp apps as a tarball (like the other libs
1011
in this directory) and installed via a `file:` dependency, so both generated code

waspc/data/Generator/libs/auth-contract/src/index.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,15 @@
55
* pages, `auth: true` operations, `context.user`, `useAuth()` -- so implementing it
66
* is all it takes to make any auth solution (Better Auth, Clerk, WorkOS, ...) a
77
* Wasp auth provider.
8+
*
9+
* An adapter package implements this contract in its server entry and exposes it
10+
* as a named `createServerAdapter` export (see `ServerAdapterFactory`). Its
11+
* client side, if it has one, ships as ordinary React exports on the package's
12+
* own `/client` entry -- Wasp generates no client glue.
813
*/
914

15+
import type { IncomingMessage, ServerResponse } from "node:http";
16+
1017
export type JsonValue =
1118
| string
1219
| number
@@ -199,3 +206,107 @@ export function canManageSessions(
199206
typeof p.revokeAllSessions === "function"
200207
);
201208
}
209+
210+
/**
211+
* Everything Wasp hands a server-side adapter about the app it runs in.
212+
*
213+
* This is the adapter's *only* window into the app: adapters must not import
214+
* generated code (`wasp/...`) and must not read `process.env` themselves. Keeping
215+
* the boundary here is what lets an adapter package typecheck and version
216+
* independently of any particular Wasp app.
217+
*/
218+
export type WaspServerRuntime = {
219+
/**
220+
* The app's PrismaClient instance. Typed as `unknown` because the client's type
221+
* is generated per app; adapters that need it narrow it themselves.
222+
*/
223+
db: unknown;
224+
225+
/**
226+
* The Prisma datasource provider of the app's database: `"sqlite"`,
227+
* `"postgresql"`, ... Adapters that bring their own storage layer (Better
228+
* Auth's prisma adapter, for one) need to know the dialect they are talking to.
229+
*/
230+
dbProvider: string;
231+
232+
/**
233+
* The server-side environment, already validated against the env vars the
234+
* adapter's manifest declared.
235+
*/
236+
env: Record<string, string | undefined>;
237+
238+
/** The URL the Wasp server is reachable at. */
239+
serverUrl: string;
240+
241+
/** The URL the Wasp client is served from. Useful for trusted-origin checks. */
242+
clientUrl: string;
243+
244+
/**
245+
* Report that the provider just created one of its own users.
246+
*
247+
* For adapters that can observe their signup moment (in-process providers
248+
* like Better Auth; a hosted provider cannot). Wasp provisions the
249+
* corresponding local user eagerly, so it exists from signup rather than
250+
* from the first authenticated request. Calling it is optional and always
251+
* safe: provisioning is idempotent, and just-in-time provisioning on first
252+
* request remains the backstop regardless.
253+
*/
254+
onAuthUserCreated?(authUser: {
255+
subjectId: string;
256+
claims?: Record<string, JsonValue>;
257+
}): Promise<void>;
258+
};
259+
260+
/**
261+
* What an adapter's server entry produces: the provider itself, plus, for
262+
* providers that own HTTP endpoints of their own (Better Auth's `/sign-in` and
263+
* friends), the handler Wasp should mount at the manifest's `basePath`.
264+
*
265+
* One factory returns both so they are guaranteed to share one configured
266+
* instance -- a provider verifying against one configuration while its routes run
267+
* another is a bug class this shape makes unrepresentable.
268+
*/
269+
export type ServerAdapter = {
270+
provider: AuthProvider;
271+
272+
/**
273+
* Node-style request handler for the provider's own routes. Wasp mounts it at
274+
* the `basePath` the adapter's manifest declared, with the app's usual
275+
* middleware around it (minus the JSON body parser when the manifest asked for
276+
* raw bodies).
277+
*/
278+
routeHandler?: (
279+
req: IncomingMessage,
280+
res: ServerResponse,
281+
) => void | Promise<void>;
282+
};
283+
284+
/**
285+
* User-code extensions Wasp delivers alongside the serializable options.
286+
*
287+
* `setupFn` follows the same convention as Wasp's `prismaSetupFn`: a user
288+
* function the adapter calls with its integration configuration, whose return
289+
* value becomes the configuration to use. It is the escape hatch for
290+
* everything a manifest cannot carry -- functions, class instances, live
291+
* values -- so the user can reach the underlying library's full surface
292+
* (Better Auth's hooks, plugins and email callbacks, say) without giving up
293+
* the packaged adapter. Adapters should re-assert the invariants their
294+
* integration depends on (route base paths, required plugins, table name
295+
* overrides) after applying it.
296+
*/
297+
export type ServerAdapterExtensions = {
298+
setupFn?: (config: never) => unknown;
299+
};
300+
301+
/**
302+
* The required shape of an adapter package's server entry: a named
303+
* `createServerAdapter` export of this type. `options` is the serializable
304+
* configuration the adapter's spec helper captured in `main.wasp.ts`, delivered
305+
* verbatim; `extensions` carries the user-code escape hatches referenced by the
306+
* manifest.
307+
*/
308+
export type ServerAdapterFactory<Options = unknown> = (
309+
runtime: WaspServerRuntime,
310+
options: Options,
311+
extensions?: ServerAdapterExtensions,
312+
) => ServerAdapter | Promise<ServerAdapter>;

waspc/data/Generator/templates/sdk/wasp/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@
131131
{=! Server: the contract a custom auth provider implements. Adapters live in
132132
user code, so they need to import this as a normal module. =}
133133
"./server/auth/provider/types": "./dist/server/auth/provider/types.js",
134+
{=# isCustomAuthProviderUsed =}
135+
{=! Server: the selected provider and its route handler, for mounting the
136+
provider's own routes. =}
137+
"./server/auth/provider": "./dist/server/auth/provider/index.js",
138+
{=/ isCustomAuthProviderUsed =}
134139
"./server/auth/session": "./dist/server/auth/session.js",
135140
"./server/auth/utils": "./dist/server/auth/utils.js",
136141
"./server/core/auth": "./dist/server/core/auth.js",

waspc/data/Generator/templates/sdk/wasp/server/auth/provider/index.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
{{={= =}=}}
22
import { {=# isCustomAuthProviderUsed =}canManageSessions as canProviderManagesSessions, canRevokeSessions as canProviderRevokeSessions, {=/ isCustomAuthProviderUsed =}type AuthProvider } from './types.js'
33
{=# isCustomAuthProviderUsed =}
4+
{=# isPackageAuthProvider =}
5+
import { provisionAuthUser } from '../session.js'
6+
import { createServerAdapter } from '{= serverPackage =}'
7+
import { config, prisma } from '../../index.js'
8+
{=# externalSetupFn.isDefined =}
9+
{=& externalSetupFn.importStatement =}
10+
{=/ externalSetupFn.isDefined =}
11+
{=/ isPackageAuthProvider =}
12+
{=^ isPackageAuthProvider =}
413
{=& authProvider.importStatement =}
14+
{=/ isPackageAuthProvider =}
515
{=/ isCustomAuthProviderUsed =}
616
{=^ isCustomAuthProviderUsed =}
717
import { waspAuthProvider } from './wasp.js'
@@ -17,6 +27,38 @@ export {
1727
canRevokeSessions,
1828
} from './types.js'
1929

30+
{=# isPackageAuthProvider =}
31+
/**
32+
* The adapter package's server factory, called with everything it may know
33+
* about the app. This runtime object is the adapter's *only* window into the
34+
* app: adapters never import generated code and never read `process.env`
35+
* themselves, which is what lets them version independently of any app.
36+
*/
37+
const serverAdapter = await Promise.resolve(
38+
createServerAdapter(
39+
{
40+
db: prisma,
41+
dbProvider: '{= dbProvider =}',
42+
env: process.env,
43+
serverUrl: config.serverUrl,
44+
clientUrl: config.frontendUrl,
45+
// The eager-provisioning channel: an in-process adapter reports its own
46+
// signups, and the local user exists from that moment. Idempotent; JIT
47+
// provisioning on first request remains the backstop.
48+
onAuthUserCreated: async (authUser) => {
49+
await provisionAuthUser(authUser.subjectId, authUser.claims)
50+
},
51+
},
52+
{=& optionsJson =},
53+
{
54+
// The user's setup function for the adapter's underlying library; the
55+
// adapter calls it with its integration config and uses the result.
56+
setupFn: {=# externalSetupFn.isDefined =}{= externalSetupFn.importIdentifier =}{=/ externalSetupFn.isDefined =}{=^ externalSetupFn.isDefined =}undefined{=/ externalSetupFn.isDefined =},
57+
},
58+
),
59+
)
60+
61+
{=/ isPackageAuthProvider =}
2062
// PRIVATE API
2163
/**
2264
* The auth provider this app runs on.
@@ -26,7 +68,16 @@ export {
2668
* needed to authenticate against something other than Wasp's own auth.
2769
*/
2870
export const authProvider: AuthProvider =
29-
{=# isCustomAuthProviderUsed =}{= authProvider.importIdentifier =}{=/ isCustomAuthProviderUsed =}{=^ isCustomAuthProviderUsed =}waspAuthProvider{=/ isCustomAuthProviderUsed =}
71+
{=# isCustomAuthProviderUsed =}{=# isPackageAuthProvider =}serverAdapter.provider{=/ isPackageAuthProvider =}{=^ isPackageAuthProvider =}{= authProvider.importIdentifier =}{=/ isPackageAuthProvider =}{=/ isCustomAuthProviderUsed =}{=^ isCustomAuthProviderUsed =}waspAuthProvider{=/ isCustomAuthProviderUsed =}
72+
{=# isPackageAuthProvider =}
73+
74+
// PRIVATE API
75+
/**
76+
* Node handler for the provider's own routes, if it brought any. The server
77+
* mounts it at the basePath the manifest declared.
78+
*/
79+
export const authProviderRouteHandler = serverAdapter.routeHandler
80+
{=/ isPackageAuthProvider =}
3081
{=# isCustomAuthProviderUsed =}
3182

3283
/**

waspc/data/Generator/templates/sdk/wasp/server/auth/provider/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,16 @@ export type RegisteredAuthProviderUserSignupFields = FromRegister<
4343
'authProviderUserSignupFields',
4444
UserSignupFields
4545
>
46+
47+
// PRIVATE API
48+
/**
49+
* The `setupFn` the developer registered on the external provider's manifest,
50+
* if any: the setup function (the `prismaSetupFn` convention) that lets an app
51+
* reach the underlying auth library's full configuration surface (hooks,
52+
* plugins, email callbacks) while keeping the packaged adapter. The adapter
53+
* types its parameter precisely; here it only has to be *a* function.
54+
*/
55+
export type RegisteredAuthProviderSetupFn = FromRegister<
56+
'authProviderSetupFn',
57+
(config: never) => unknown
58+
>

waspc/data/Generator/templates/sdk/wasp/server/auth/session.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,20 @@ function isUniqueConstraintViolation(e: unknown): boolean {
212212
typeof e === 'object' && e !== null && 'code' in e && (e as { code: unknown }).code === 'P2002'
213213
);
214214
}
215+
216+
// PRIVATE API
217+
/**
218+
* Eager provisioning: the runtime channel an in-process adapter calls when it
219+
* observes its own signup, so the local user exists from that moment instead
220+
* of from the first authenticated request. Same code path as just-in-time
221+
* provisioning, called sooner -- idempotent by the same unique constraint.
222+
*/
223+
export async function provisionAuthUser(
224+
subjectId: string,
225+
claims: VerifiedSession['claims'],
226+
): Promise<void> {
227+
await resolveExternalSubject(subjectId, claims);
228+
}
215229
{=/ isCustomAuthProviderUsed =}
216230

217231
// PRIVATE API

waspc/data/Generator/templates/server/src/routes/index.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { globalMiddlewareConfigForExpress } from '../middleware/index.js'
55
{=# isAuthEnabled =}
66
import auth from './auth/index.js'
77
{=/ isAuthEnabled =}
8+
{=# externalAuthProviderRoutes =}
9+
import { authProviderRouteHandler } from 'wasp/server/auth/provider'
10+
{=/ externalAuthProviderRoutes =}
811
{=# areThereAnyCustomApiRoutes =}
912
import apis from './apis/index.js'
1013
{=/ areThereAnyCustomApiRoutes =}
@@ -41,6 +44,25 @@ router.get('/', middleware,
4144
{=# isAuthEnabled =}
4245
router.use('/auth', middleware, auth)
4346
{=/ isAuthEnabled =}
47+
{=# externalAuthProviderRoutes =}
48+
// The external auth provider's own routes, mounted where its manifest asked.
49+
// The usual middleware stack applies{=# rawBody =}, minus the body parsers: the
50+
// provider's handler reads the raw request stream itself, and a body that was
51+
// already consumed would make every request to it hang{=/ rawBody =}.
52+
const authProviderMiddleware = globalMiddlewareConfigForExpress((middlewareConfig) => {
53+
{=# rawBody =}
54+
middlewareConfig.delete('express.json')
55+
middlewareConfig.delete('express.urlencoded')
56+
{=/ rawBody =}
57+
return middlewareConfig
58+
})
59+
router.use('{= basePath =}', authProviderMiddleware, (req, res, next) => {
60+
if (authProviderRouteHandler === undefined) {
61+
return next(new Error('The auth provider manifest declares routes, but its server adapter returned no routeHandler.'))
62+
}
63+
return Promise.resolve(authProviderRouteHandler(req, res)).catch(next)
64+
})
65+
{=/ externalAuthProviderRoutes =}
4466
router.use('/{= operationsRouteInRootRouter =}', middleware, operations)
4567
{=# areThereAnyCrudRoutes =}
4668
router.use('/{= crudRouteInRootRouter =}', middleware, rootCrudRouter)

waspc/data/Generator/templates/types/app/sdk/register.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ declare module "wasp/types" {
1818
{=# authProviderUserSignupFields.isDefined =}
1919
authProviderUserSignupFields: typeof {=& authProviderUserSignupFields.dynamicImportExpression =}
2020
{=/ authProviderUserSignupFields.isDefined =}
21+
{=# authProviderSetupFn.isDefined =}
22+
authProviderSetupFn: typeof {=& authProviderSetupFn.dynamicImportExpression =}
23+
{=/ authProviderSetupFn.isDefined =}
2124
{=# serverEnvValidationSchema.isDefined =}
2225
serverEnvValidationSchema: typeof {=& serverEnvValidationSchema.dynamicImportExpression =}
2326
{=/ serverEnvValidationSchema.isDefined =}

waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/server/auth/provider/types.ts

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)