@kelpie/server 0.11.0 → 0.12.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 (48) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/errors.d.ts +18 -0
  6. package/dist/lib/errors.d.ts.map +1 -1
  7. package/dist/lib/errors.js +17 -0
  8. package/dist/lib/errors.js.map +1 -1
  9. package/dist/modules/auth/index.d.ts.map +1 -1
  10. package/dist/modules/auth/index.js +21 -4
  11. package/dist/modules/auth/index.js.map +1 -1
  12. package/dist/modules/auth/repository.d.ts +2 -1
  13. package/dist/modules/auth/repository.d.ts.map +1 -1
  14. package/dist/modules/auth/repository.js +1 -0
  15. package/dist/modules/auth/repository.js.map +1 -1
  16. package/dist/modules/auth/routes.d.ts.map +1 -1
  17. package/dist/modules/auth/routes.js +2 -6
  18. package/dist/modules/auth/routes.js.map +1 -1
  19. package/dist/modules/auth/schema.d.ts +18 -1
  20. package/dist/modules/auth/schema.d.ts.map +1 -1
  21. package/dist/modules/auth/schema.js +12 -1
  22. package/dist/modules/auth/schema.js.map +1 -1
  23. package/dist/modules/auth/service.d.ts +18 -0
  24. package/dist/modules/auth/service.d.ts.map +1 -1
  25. package/dist/modules/auth/service.js +95 -6
  26. package/dist/modules/auth/service.js.map +1 -1
  27. package/dist/modules/auth/session.d.ts +12 -0
  28. package/dist/modules/auth/session.d.ts.map +1 -1
  29. package/dist/modules/auth/session.js +12 -0
  30. package/dist/modules/auth/session.js.map +1 -1
  31. package/dist/runtime/module.d.ts +63 -1
  32. package/dist/runtime/module.d.ts.map +1 -1
  33. package/dist/runtime/registry.d.ts.map +1 -1
  34. package/dist/runtime/registry.js +40 -2
  35. package/dist/runtime/registry.js.map +1 -1
  36. package/migrations/0043_external_sign_in.sql +2 -0
  37. package/migrations/meta/_journal.json +7 -0
  38. package/package.json +2 -2
  39. package/src/index.ts +13 -1
  40. package/src/lib/errors.ts +30 -0
  41. package/src/modules/auth/index.ts +25 -4
  42. package/src/modules/auth/repository.ts +2 -1
  43. package/src/modules/auth/routes.ts +2 -8
  44. package/src/modules/auth/schema.ts +12 -1
  45. package/src/modules/auth/service.ts +143 -6
  46. package/src/modules/auth/session.ts +14 -0
  47. package/src/runtime/module.ts +70 -1
  48. package/src/runtime/registry.ts +54 -0
@@ -5,6 +5,7 @@ import type { KelpieModule } from '../../runtime/module.ts'
5
5
  import { mountAuthRoutes } from './routes.ts'
6
6
  import * as schema from './schema.ts'
7
7
  import { createAuthService } from './service.ts'
8
+ import { describeClient, writeSessionCookie } from './session.ts'
8
9
 
9
10
  /**
10
11
  * Accounts, sessions, and password recovery.
@@ -38,6 +39,11 @@ export function createAuthModule(migrationsDirectory: string): KelpieModule {
38
39
  appBaseUrl,
39
40
  })
40
41
 
42
+ // `Secure` everywhere except development. A test host reaches the API
43
+ // over http through a test client, which stores the cookie regardless of
44
+ // the flag, so test is not excluded.
45
+ const cookie = { secure: NODE_ENV !== 'development' }
46
+
41
47
  context.schema(schema, migrationsDirectory)
42
48
 
43
49
  context.routes((router) => {
@@ -45,11 +51,26 @@ export function createAuthModule(migrationsDirectory: string): KelpieModule {
45
51
  db: context.db,
46
52
  now: context.now,
47
53
  service,
48
- // `Secure` everywhere except development. A test host reaches the API
49
- // over http through a test client, which stores the cookie regardless
50
- // of the flag, so test is not excluded.
51
- cookie: { secure: NODE_ENV !== 'development' },
54
+ cookie,
55
+ })
56
+ })
57
+
58
+ // How a module signs a browser in. It verifies an identity its own way
59
+ // and hands it here; everything from the cookie onwards is what a
60
+ // password sign-in does, through the same helper with the same flags.
61
+ context.provideExternalSignIn(async (honoContext, identity) => {
62
+ const issued = await service.completeExternalSignIn({
63
+ ...identity,
64
+ ...describeClient(honoContext),
52
65
  })
66
+
67
+ writeSessionCookie(honoContext, issued.sessionToken, cookie)
68
+
69
+ return {
70
+ account: issued.account,
71
+ created: issued.created,
72
+ activeWorkspaceId: issued.activeWorkspaceId,
73
+ }
53
74
  })
54
75
 
55
76
  return Promise.resolve()
@@ -48,10 +48,11 @@ export async function insertUser(db: Queryable, values: typeof users.$inferInser
48
48
  return created
49
49
  }
50
50
 
51
+ /** `null` clears the password, leaving the account reachable only through a module or a reset. */
51
52
  export async function updateUserPassword(
52
53
  db: Queryable,
53
54
  userId: string,
54
- passwordHash: string,
55
+ passwordHash: string | null,
55
56
  now: Date,
56
57
  ): Promise<void> {
57
58
  await db
@@ -11,7 +11,7 @@ import { resolveActorFrom } from './credentials.ts'
11
11
  import type { CredentialDependencies } from './credentials.ts'
12
12
  import type { PreferenceValues } from './preferences.ts'
13
13
  import type { AccountView, AuthService, IssuedSession, SessionView } from './service.ts'
14
- import { clearSessionCookie, writeSessionCookie } from './session.ts'
14
+ import { clearSessionCookie, describeClient, writeSessionCookie } from './session.ts'
15
15
  import type { SessionCookieOptions } from './session.ts'
16
16
 
17
17
  /**
@@ -108,13 +108,6 @@ async function readBody<T>(context: Context, schema: z.ZodType<T>): Promise<T> {
108
108
  return parsed.data
109
109
  }
110
110
 
111
- /** The device and location shown on the Security page come from the request itself. */
112
- function describeClient(context: Context): { device?: string; location?: string } {
113
- const userAgent = context.req.header('User-Agent')
114
-
115
- return userAgent === undefined ? {} : { device: userAgent }
116
- }
117
-
118
111
  function accountResponse(issued: IssuedSession): Record<string, unknown> {
119
112
  return {
120
113
  account: accountBody(issued.account),
@@ -149,6 +142,7 @@ function sessionResponse(session: SessionView): Record<string, unknown> {
149
142
  location: session.location,
150
143
  last_active_at: session.lastActiveAt.toISOString(),
151
144
  current: session.current,
145
+ signed_in_via: session.signedInVia,
152
146
  }
153
147
  }
154
148
 
@@ -18,7 +18,12 @@ export const users = pgTable('users', {
18
18
  id: primaryId(),
19
19
  email: citext('email').notNull().unique(),
20
20
  name: text('name').notNull(),
21
- passwordHash: text('password_hash').notNull(),
21
+ /**
22
+ * Null for an account that has only ever signed in through an identity
23
+ * module. Password sign-in is refused while it is null; a password is set
24
+ * through the reset flow, whose emailed link proves control of the address.
25
+ */
26
+ passwordHash: text('password_hash'),
22
27
  /** Null until the address is verified, either by confirming a token or by accepting a workspace invite. */
23
28
  emailVerifiedAt: moment('email_verified_at'),
24
29
  createdAt: createdAt(),
@@ -34,6 +39,12 @@ export const sessions = pgTable('sessions', {
34
39
  tokenHash: text('token_hash').notNull().unique(),
35
40
  device: text('device'),
36
41
  location: text('location'),
42
+ /**
43
+ * Which module completed this sign-in (`sign-in:google`), null for a password
44
+ * one. The Security page lists sessions, and a person should be able to tell
45
+ * the two apart.
46
+ */
47
+ signedInVia: text('signed_in_via'),
37
48
  lastActiveAt: moment('last_active_at').notNull().defaultNow(),
38
49
  expiresAt: moment('expires_at').notNull(),
39
50
  createdAt: createdAt(),
@@ -1,12 +1,13 @@
1
1
  import { APP_LINK_PATHS, buildAppLink } from '../../lib/appUrl.ts'
2
2
  import type { EmailSender } from '../../lib/email.ts'
3
3
  import { renderEmail } from '../../lib/emailContent.ts'
4
- import { AppError } from '../../lib/errors.ts'
4
+ import { AppError, ExternalSignInError } from '../../lib/errors.ts'
5
5
  import type { IdFactory } from '../../lib/ids.ts'
6
6
  import { UNIQUE_VIOLATION, postgresErrorCode } from '../../lib/database.ts'
7
7
  import type { Database } from '../../lib/database.ts'
8
8
  import { MINIMUM_PASSWORD_LENGTH, hashPassword, verifyPassword } from '../../lib/passwords.ts'
9
9
  import { generateToken, hashToken } from '../../lib/tokens.ts'
10
+ import type { VerifiedIdentity } from '../../runtime/module.ts'
10
11
  import type { TransactionScope } from '../../runtime/transaction.ts'
11
12
  import type { SessionActor } from './actor.ts'
12
13
  import { DEFAULT_PREFERENCES, applyPreferenceChanges } from './preferences.ts'
@@ -54,12 +55,19 @@ export interface IssuedSession {
54
55
  readonly activeWorkspaceId: string | null
55
56
  }
56
57
 
58
+ /** What `completeExternalSignIn` returns: an issued session plus whether it made the account. */
59
+ export interface ExternalIssuedSession extends IssuedSession {
60
+ readonly created: boolean
61
+ }
62
+
57
63
  export interface SessionView {
58
64
  readonly id: string
59
65
  readonly device: string | null
60
66
  readonly location: string | null
61
67
  readonly lastActiveAt: Date
62
68
  readonly current: boolean
69
+ /** The module that signed this session in, or null for a password sign-in. */
70
+ readonly signedInVia: string | null
63
71
  }
64
72
 
65
73
  export interface SignUpInput {
@@ -85,6 +93,12 @@ export interface LogInInput {
85
93
  readonly location?: string
86
94
  }
87
95
 
96
+ /** A `VerifiedIdentity` plus what the request itself says about the client. */
97
+ export interface ExternalSignInInput extends VerifiedIdentity {
98
+ readonly device?: string
99
+ readonly location?: string
100
+ }
101
+
88
102
  function toAccountView(user: repository.UserRecord): AccountView {
89
103
  return { id: user.id, email: user.email, name: user.name, emailVerified: user.emailVerifiedAt !== null }
90
104
  }
@@ -105,6 +119,16 @@ function normaliseEmail(email: string): string {
105
119
  return email.trim().toLowerCase()
106
120
  }
107
121
 
122
+ /**
123
+ * The name to use when a provider sends none. `users.name` is not nullable and
124
+ * an address is the only other thing an identity is guaranteed to carry.
125
+ */
126
+ function localPartOf(email: string): string {
127
+ const [local] = email.split('@')
128
+
129
+ return local === undefined || local.length === 0 ? email : local
130
+ }
131
+
108
132
  /**
109
133
  * Rejects a field that is only whitespace.
110
134
  *
@@ -128,6 +152,12 @@ export interface AuthService {
128
152
  /** Creates the account only. The first workspace comes from onboarding. */
129
153
  signUp(input: SignUpInput): Promise<IssuedSession>
130
154
  logIn(input: LogInInput): Promise<IssuedSession>
155
+ /**
156
+ * Signs in an identity a module verified elsewhere, provisioning the account
157
+ * when the module asked for it. Never sends a verification email: the
158
+ * provider already proved control of the address.
159
+ */
160
+ completeExternalSignIn(input: ExternalSignInInput): Promise<ExternalIssuedSession>
131
161
  logOut(actor: SessionActor): Promise<void>
132
162
  getAccount(actor: SessionActor): Promise<AccountView>
133
163
  updateAccount(actor: SessionActor, changes: UpdateAccountChanges): Promise<AccountView>
@@ -167,6 +197,8 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
167
197
  user: repository.UserRecord,
168
198
  device: string | undefined,
169
199
  location: string | undefined,
200
+ /** The module that verified the identity, or null for a password sign-in. */
201
+ signedInVia: string | null = null,
170
202
  ): Promise<IssuedSession> {
171
203
  const now = dependencies.now()
172
204
  const token = newToken()
@@ -180,6 +212,7 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
180
212
  tokenHash: hashToken(token),
181
213
  device: device ?? null,
182
214
  location: location ?? null,
215
+ signedInVia,
183
216
  lastActiveAt: now,
184
217
  expiresAt,
185
218
  })
@@ -269,17 +302,113 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
269
302
  async logIn(input: LogInInput): Promise<IssuedSession> {
270
303
  const user = await repository.findUserByEmail(dependencies.db, normaliseEmail(input.email))
271
304
 
272
- // Hash a throwaway password when the user is unknown, so a missing account
273
- // and a wrong password take the same time to answer.
305
+ // Hash a throwaway password when the account is unknown or has no
306
+ // password, so all three cases take the same time to answer.
274
307
  const storedHash = user?.passwordHash ?? (await hashPassword('not-a-real-password-placeholder'))
275
-
276
- if (!(await verifyPassword(storedHash, input.password)) || user === undefined) {
308
+ // Always run the verify, even when the hash is the placeholder: skipping
309
+ // it would make a passwordless account answer faster than a wrong one.
310
+ const matches = await verifyPassword(storedHash, input.password)
311
+
312
+ // The null check is the guard, not the placeholder hash. Without it,
313
+ // whoever guessed the placeholder string would sign in to every account
314
+ // that has no password.
315
+ if (user === undefined || user.passwordHash === null || !matches) {
277
316
  throw AppError.unauthorized('Email or password is incorrect')
278
317
  }
279
318
 
280
319
  return dependencies.transaction(({ tx }) => issueSession(tx, user, input.device, input.location))
281
320
  },
282
321
 
322
+ /**
323
+ * Signs in an identity a module already verified.
324
+ *
325
+ * Core does not redo the verification and never learns the protocol. What
326
+ * it owns is everything downstream: finding or provisioning the account,
327
+ * issuing the session, and recording which module vouched for it.
328
+ */
329
+ async completeExternalSignIn(input: ExternalSignInInput): Promise<ExternalIssuedSession> {
330
+ // An unverified address is not an identity. A provider that does not say
331
+ // it checked is treated as not having checked.
332
+ if (!input.emailVerified) {
333
+ throw new ExternalSignInError('email_unverified')
334
+ }
335
+
336
+ const email = requireText(normaliseEmail(input.email), 'email')
337
+ const name = requireText(input.name ?? localPartOf(email), 'name')
338
+ const now = dependencies.now()
339
+
340
+ const link = async (
341
+ tx: repository.Queryable,
342
+ user: repository.UserRecord,
343
+ ): Promise<ExternalIssuedSession> => {
344
+ // Signing in through a provider on an account whose address was never
345
+ // confirmed is the pre-registration takeover case: somebody registered
346
+ // this address with a password and never proved they own it. The
347
+ // provider just proved the opposite. Drop that password and every
348
+ // session it opened; the real owner sets a new one through a reset.
349
+ if (user.emailVerifiedAt === null) {
350
+ await repository.updateUserPassword(tx, user.id, null, now)
351
+ await repository.deleteAllSessionsForUser(tx, user.id)
352
+ }
353
+
354
+ // The provider proved control of the address, which is the same thing
355
+ // accepting an invite proves.
356
+ await repository.markEmailVerified(tx, user.id, now)
357
+
358
+ const issued = await issueSession(
359
+ tx,
360
+ { ...user, emailVerifiedAt: user.emailVerifiedAt ?? now },
361
+ input.device,
362
+ input.location,
363
+ input.verifiedBy,
364
+ )
365
+
366
+ return { ...issued, created: false }
367
+ }
368
+
369
+ const existing = await repository.findUserByEmail(dependencies.db, email)
370
+
371
+ if (existing !== undefined) {
372
+ return dependencies.transaction(({ tx }) => link(tx, existing))
373
+ }
374
+
375
+ if (input.provision === 'refuse') {
376
+ throw new ExternalSignInError('unknown_identity')
377
+ }
378
+
379
+ return dependencies.transaction(async ({ tx }) => {
380
+ let user: repository.UserRecord
381
+
382
+ try {
383
+ user = await repository.insertUser(tx, {
384
+ id: dependencies.createId('user'),
385
+ email,
386
+ name,
387
+ passwordHash: null,
388
+ emailVerifiedAt: now,
389
+ })
390
+ } catch (error: unknown) {
391
+ if (postgresErrorCode(error) !== UNIQUE_VIOLATION) {
392
+ throw error
393
+ }
394
+
395
+ // Two first sign-ins for the same address at once. The other one won;
396
+ // this is now an ordinary link.
397
+ const raced = await repository.findUserByEmail(tx, email)
398
+
399
+ if (raced === undefined) {
400
+ throw error
401
+ }
402
+
403
+ return link(tx, raced)
404
+ }
405
+
406
+ const issued = await issueSession(tx, user, input.device, input.location, input.verifiedBy)
407
+
408
+ return { ...issued, created: true }
409
+ })
410
+ },
411
+
283
412
  async logOut(actor: SessionActor): Promise<void> {
284
413
  await repository.deleteSession(dependencies.db, actor.userId, actor.sessionId)
285
414
  },
@@ -308,9 +437,12 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
308
437
  : { email: requireText(normaliseEmail(changes.email), 'email') }),
309
438
  }
310
439
 
440
+ // A passwordless account cannot prove itself this way. It sets a password
441
+ // through the reset flow first, which is the same bar this check is.
311
442
  if (
312
443
  changes.email !== undefined &&
313
444
  (changes.currentPassword === undefined ||
445
+ user.passwordHash === null ||
314
446
  !(await verifyPassword(user.passwordHash, changes.currentPassword)))
315
447
  ) {
316
448
  throw AppError.unauthorized('Current password is incorrect')
@@ -405,6 +537,7 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
405
537
  location: record.location,
406
538
  lastActiveAt: record.lastActiveAt,
407
539
  current: record.id === actor.sessionId,
540
+ signedInVia: record.signedInVia,
408
541
  }))
409
542
  },
410
543
 
@@ -494,7 +627,11 @@ export function createAuthService(dependencies: AuthDependencies): AuthService {
494
627
 
495
628
  const user = await repository.findUserById(dependencies.db, actor.userId)
496
629
 
497
- if (user === undefined || !(await verifyPassword(user.passwordHash, currentPassword))) {
630
+ if (
631
+ user === undefined ||
632
+ user.passwordHash === null ||
633
+ !(await verifyPassword(user.passwordHash, currentPassword))
634
+ ) {
498
635
  throw AppError.unauthorized('Current password is incorrect')
499
636
  }
500
637
 
@@ -11,6 +11,20 @@ import type { Context } from 'hono'
11
11
 
12
12
  export const SESSION_COOKIE = 'kelpie_session'
13
13
 
14
+ /**
15
+ * The device and location shown on the Security page come from the request
16
+ * itself.
17
+ *
18
+ * Here rather than in `routes.ts` because the external sign-in handler needs
19
+ * the same description, and a session created by a module should look the same
20
+ * on that page as one created by the login form.
21
+ */
22
+ export function describeClient(context: Context): { device?: string; location?: string } {
23
+ const userAgent = context.req.header('User-Agent')
24
+
25
+ return userAgent === undefined ? {} : { device: userAgent }
26
+ }
27
+
14
28
  /** Thirty days, matching the session row's expiry. */
15
29
  const COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
16
30
 
@@ -1,4 +1,4 @@
1
- import type { Handler, Hono, MiddlewareHandler } from 'hono'
1
+ import type { Context, Handler, Hono, MiddlewareHandler } from 'hono'
2
2
  import type { ZodType } from 'zod'
3
3
 
4
4
  import type { Actor } from '../lib/actor.ts'
@@ -58,6 +58,53 @@ export interface McpToolRegistry {
58
58
  tool<Input>(definition: McpToolDefinition<Input>): void
59
59
  }
60
60
 
61
+ /**
62
+ * An identity a module verified somewhere else, handed back for core to sign in.
63
+ *
64
+ * Core never learns what OIDC or SAML is. Verification is the module's job and
65
+ * core does not redo it; core's job is that everything downstream of the cookie
66
+ * is identical to a password sign-in.
67
+ */
68
+ export interface VerifiedIdentity {
69
+ readonly email: string
70
+ /** Core refuses the identity when false, rather than assuming the module checked. */
71
+ readonly emailVerified: boolean
72
+ /** The provider's display name, when it has one. The local part of the address is used otherwise. */
73
+ readonly name: string | null
74
+ /** Who verified it, recorded on the session: the module id, optionally suffixed (`sign-in:google`). */
75
+ readonly verifiedBy: string
76
+ /**
77
+ * What to do with an address core has never seen. The policy is the module's,
78
+ * because only it knows whether its provider vouches for strangers; executing
79
+ * it is core's.
80
+ */
81
+ readonly provision: 'create' | 'refuse'
82
+ }
83
+
84
+ /** The account a completed sign-in belongs to. */
85
+ export interface CompletedSignInAccount {
86
+ readonly id: string
87
+ readonly email: string
88
+ readonly name: string
89
+ readonly emailVerified: boolean
90
+ }
91
+
92
+ export interface CompletedSignIn {
93
+ readonly account: CompletedSignInAccount
94
+ /** True when this sign-in provisioned the account rather than finding it. */
95
+ readonly created: boolean
96
+ readonly activeWorkspaceId: string | null
97
+ }
98
+
99
+ /**
100
+ * Completes a sign-in. Takes the Hono context because writing the session
101
+ * cookie on it is the point.
102
+ */
103
+ export type ExternalSignInHandler = (
104
+ context: Context,
105
+ identity: VerifiedIdentity,
106
+ ) => Promise<CompletedSignIn>
107
+
61
108
  /**
62
109
  * What a module gets to build with, beyond its own contributions.
63
110
  *
@@ -119,6 +166,28 @@ export interface ModuleContext extends ModuleServices {
119
166
  * delegates to the provider `email.provider` picked.
120
167
  */
121
168
  readonly email: EmailSender
169
+ /**
170
+ * Installs the one implementation of external sign-in. Core's `auth` module
171
+ * calls this; a second caller fails boot.
172
+ *
173
+ * The same shape as `provideEmailSender`: a capability only one module owns,
174
+ * reached by every module through a proxy, so registration order does not
175
+ * matter to a consumer.
176
+ */
177
+ provideExternalSignIn(handler: ExternalSignInHandler): void
178
+ /**
179
+ * Completes a sign-in for an identity this module already verified: finds or
180
+ * provisions the account, issues a session, and writes the session cookie on
181
+ * `context`.
182
+ *
183
+ * The alternative is a module writing core's `sessions` table itself, which
184
+ * ties it to core's token hashing, cookie flags and expiry with nothing
185
+ * keeping the two in step (`modules.md`).
186
+ *
187
+ * @throws ExternalSignInError when the identity is unverified, or unknown and
188
+ * the module asked for `provision: 'refuse'`.
189
+ */
190
+ completeExternalSignIn(context: Context, identity: VerifiedIdentity): Promise<CompletedSignIn>
122
191
  /**
123
192
  * Registers routes that mount under `/v1/public`, take no credentials, and
124
193
  * answer cross-origin requests from any site.
@@ -13,12 +13,15 @@ import type { EntitlementRegistry } from './entitlements.ts'
13
13
  import { createEventBus } from './events.ts'
14
14
  import type { EventBus } from './events.ts'
15
15
  import type {
16
+ CompletedSignIn,
17
+ ExternalSignInHandler,
16
18
  KelpieModule,
17
19
  McpTool,
18
20
  ModuleCatalogEntry,
19
21
  ModuleContext,
20
22
  ModuleServices,
21
23
  SchemaContribution,
24
+ VerifiedIdentity,
22
25
  } from './module.ts'
23
26
  import { createModuleConfigProvider, moduleCapabilityName, validateModuleConfig } from './moduleConfig.ts'
24
27
  import { ModuleBootError, orderModules } from './order.ts'
@@ -180,6 +183,46 @@ class EmailSenderProxy implements EmailSender {
180
183
  }
181
184
  }
182
185
 
186
+ /**
187
+ * Completes a sign-in for an identity a module verified elsewhere.
188
+ *
189
+ * Same shape as `EmailSenderProxy`, and for the same reason: one module owns
190
+ * the implementation, every module reaches it through this object, and a
191
+ * consumer captures the proxy at register time but only calls it at request
192
+ * time, so registration order does not matter.
193
+ *
194
+ * Exactly one installer, unlike email's named registry: there is one `users`
195
+ * table and one session cookie, so "which implementation" is never a question
196
+ * an assembly should have to answer.
197
+ */
198
+ class ExternalSignInProxy {
199
+ private target: ExternalSignInHandler | undefined = undefined
200
+ private installedBy: string | undefined = undefined
201
+
202
+ install(moduleId: string, handler: ExternalSignInHandler): void {
203
+ if (this.installedBy !== undefined) {
204
+ throw new ModuleBootError([
205
+ `module "${moduleId}" provides external sign-in, but module "${this.installedBy}" already did`,
206
+ ])
207
+ }
208
+
209
+ this.target = handler
210
+ this.installedBy = moduleId
211
+ }
212
+
213
+ complete(context: Context, identity: VerifiedIdentity): Promise<CompletedSignIn> {
214
+ if (this.target === undefined) {
215
+ return Promise.reject(
216
+ new Error(
217
+ 'external sign-in used before a module installed it. Is the auth module in the assembly?',
218
+ ),
219
+ )
220
+ }
221
+
222
+ return this.target(context, identity)
223
+ }
224
+ }
225
+
183
226
  /**
184
227
  * The name the runtime uses for its built-in log sender. Reserved: a module
185
228
  * that tries to register under this name fails boot.
@@ -204,11 +247,20 @@ function createModuleContext(
204
247
  moduleCatalog: readonly ModuleCatalogEntry[],
205
248
  emailProxy: EmailSenderProxy,
206
249
  providers: Map<string, RegisteredProvider>,
250
+ externalSignIn: ExternalSignInProxy,
207
251
  ): ModuleContext {
208
252
  return {
209
253
  ...options.services,
210
254
  email: emailProxy,
211
255
 
256
+ provideExternalSignIn(handler) {
257
+ externalSignIn.install(module.id, handler)
258
+ },
259
+
260
+ completeExternalSignIn(context, identity) {
261
+ return externalSignIn.complete(context, identity)
262
+ },
263
+
212
264
  provideEmailSender(name, build) {
213
265
  if (name === LOG_PROVIDER_NAME) {
214
266
  throw new ModuleBootError([
@@ -348,6 +400,7 @@ export async function registerModules(options: ModuleRuntimeOptions): Promise<Mo
348
400
  const entitlements = options.entitlements ?? createEntitlementRegistry()
349
401
  const emailProxy = new EmailSenderProxy()
350
402
  const emailProviders = new Map<string, RegisteredProvider>()
403
+ const externalSignIn = new ExternalSignInProxy()
351
404
 
352
405
  // The built-in log provider is always available, no module required. Named
353
406
  // 'log' in kelpie.config.ts's email.provider picks this one. Built eagerly
@@ -429,6 +482,7 @@ export async function registerModules(options: ModuleRuntimeOptions): Promise<Mo
429
482
  moduleCatalog,
430
483
  emailProxy,
431
484
  emailProviders,
485
+ externalSignIn,
432
486
  )
433
487
 
434
488
  try {