lecodes-sdk 2.0.4 → 2.0.5

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 (72) hide show
  1. package/README.md +104 -76
  2. package/dist/global.d.ts +2 -5
  3. package/dist/host.d.ts +3 -0
  4. package/dist/types/inject.d.ts +3 -3
  5. package/dist/types/runtime/device.d.ts +7 -0
  6. package/dist/types/runtime/rpc.d.ts +11 -17
  7. package/dist/types/runtime/wire.d.ts +53 -0
  8. package/dist/types/server/auth/api.d.ts +42 -0
  9. package/dist/types/server/auth/appConfig.d.ts +1 -5
  10. package/dist/types/server/auth/models.d.ts +119 -70
  11. package/dist/types/server/auth/types.d.ts +19 -43
  12. package/dist/types/server/channel.d.ts +57 -19
  13. package/dist/types/server/context.d.ts +2 -2
  14. package/dist/types/server/db/defineDb.d.ts +10 -0
  15. package/dist/types/server/db/index.d.ts +1 -1
  16. package/dist/types/server/db/types.d.ts +76 -6
  17. package/dist/types/server/inject.d.ts +0 -1
  18. package/dist/types/ui/UINode.d.ts +19 -5
  19. package/dist/types/ui/UIScreen.d.ts +1 -0
  20. package/dist/types/ui/UITabs.d.ts +8 -6
  21. package/dist/types/ui/theme.d.ts +48 -13
  22. package/dist/types/version.d.ts +1 -1
  23. package/dist/types.json +1 -1
  24. package/package.json +3 -2
  25. package/prompts/README.md +1 -1
  26. package/prompts/design.md +19 -19
  27. package/prompts/dist/2d-game.md +45 -31
  28. package/prompts/dist/3d-app.md +45 -31
  29. package/prompts/dist/ar-app.md +45 -31
  30. package/prompts/dist/design.md +25 -24
  31. package/prompts/dist/ui-app.md +45 -31
  32. package/prompts/ui-design.md +6 -5
  33. package/prompts/ui.md +25 -22
  34. package/src/bridges/device.d.ts +9 -0
  35. package/src/bridges/tree.d.ts +5 -0
  36. package/src/chisel.ts +1 -1
  37. package/src/compile/bundler.ts +6 -0
  38. package/src/compile/compileProject.ts +3 -1
  39. package/src/compile/index.ts +3 -1
  40. package/src/compile/serverSplit.ts +58 -11
  41. package/src/compile/serverTypes.ts +189 -8
  42. package/src/host.d.ts +3 -0
  43. package/src/inject.ts +6 -6
  44. package/src/runtime/device.ts +12 -0
  45. package/src/runtime/rpc.ts +101 -40
  46. package/src/runtime/wire.ts +35 -0
  47. package/src/server/auth/api.ts +94 -0
  48. package/src/server/auth/appConfig.ts +2 -3
  49. package/src/server/auth/host.ts +244 -174
  50. package/src/server/auth/models.ts +45 -62
  51. package/src/server/auth/types.ts +19 -34
  52. package/src/server/channel.ts +97 -29
  53. package/src/server/channelHub.ts +153 -0
  54. package/src/server/context.ts +2 -2
  55. package/src/server/db/defineDb.ts +96 -36
  56. package/src/server/db/index.ts +1 -1
  57. package/src/server/db/types.ts +76 -8
  58. package/src/server/host.ts +25 -10
  59. package/src/server/inject.ts +2 -2
  60. package/src/server/runtime.ts +34 -12
  61. package/src/ui/UINode.ts +22 -5
  62. package/src/ui/UIScreen.ts +5 -0
  63. package/src/ui/UITabs.ts +19 -17
  64. package/src/ui/styleColor.ts +10 -1
  65. package/src/ui/theme.ts +96 -41
  66. package/src/version.ts +1 -1
  67. package/tests/helpers/fakeTree.ts +1 -0
  68. package/dist/types/plugins/oauth.d.ts +0 -25
  69. package/dist/types/server/auth/global.d.ts +0 -56
  70. package/src/plugins/oauth.ts +0 -61
  71. package/src/server/auth/global.ts +0 -80
  72. package/tests/helpers/memoryMarci.ts +0 -124
@@ -1,55 +1,68 @@
1
1
  /**
2
- * The HOST side of auth (docs/backend-plan.md §3.4): sessions + tokens, the email-code flow, OAuth
3
- * binding. Runs where the runner imports `sdk/server` (the project worker), against the project's own
4
- * db handle (`server.dbs[0]` — `User`/`Session` are ordinary models, see ./models.ts). Installed on
5
- * `globalThis.__lecodesAuth` for the bundle's `auth` global (./global.ts); `resolve(ctx)` runs before
6
- * every endpoint / channel join so `auth.user` / `auth.session` are plain synchronous reads.
2
+ * The HOST side of auth: sessions + tokens, passwords, the email-code flow. Runs where
3
+ * the runner imports `sdk/server` (the project worker), over the three collections of a db made with
4
+ * `defineDb({...}).withAuth({ model })` (`db.$auth`: the project's user model, and the platform's
5
+ * `Session` and `Identity` — ./models.ts). Installed on `globalThis.__lecodesAuth` for the bundle's
6
+ * `db.auth` (./api.ts); `resolve(ctx)` runs before every endpoint / channel join and leaves the
7
+ * session and the ID of its user on the request.
8
+ *
9
+ * The user's row is the project's: this file creates one (empty, or with what a sign-up handed over)
10
+ * the first time someone signs in, and never reads or writes its fields. What a provider told about the
11
+ * person is kept on the `Identity`.
7
12
  *
8
13
  * Tokens: 32 random bytes (base64url) held by the client transport; only `sha256(token)` is stored.
9
14
  * A request without a valid token gets a fresh guest session and the token travels back in the
10
- * envelope (`session`); sign-in/out ROTATE the token the same way. OAuth binds without rotation (the
11
- * browser completes the flow, the app can't receive a new token) — see `oauthComplete`.
15
+ * envelope (`session`); sign-in/out ROTATE the token the same way.
16
+ *
17
+ * Identities: every sign-in method is an `Identity` row `(provider, subject)` of a user — `email:<address>`
18
+ * or `login:<name>` (its `hash` = the password). The one rule that keeps passwords honest: proving an
19
+ * email (a code) on an identity that was never verified DROPS its password — a password set before the
20
+ * address was confirmed proves nothing (pre-account-takeover guard).
21
+ *
22
+ * Sign-in through a provider (Google, Apple, …) is not here: a project that verifies a person some
23
+ * other way binds the device itself with `db.auth.signIn(userId)`.
12
24
  */
13
25
 
26
+ import { randomBytes, scrypt as scryptCb, timingSafeEqual } from "node:crypto"
14
27
  import type { RequestContext } from "../context"
15
28
  import type { AuthBinding } from "../db/types"
16
29
  import { ApiError } from "../errors"
17
- import type { AuthProvider } from "./models"
18
- import type { AuthOps, AuthState, AuthUser, OAuthCredential, OAuthProfile } from "./types"
30
+ import type { IdentityProvider } from "./models"
31
+ import type { AuthOps, AuthState } from "./types"
19
32
 
20
33
  export type AuthMail = { to: string, code: string, appName: string }
21
34
 
22
35
  export type AuthHostConfig = {
23
- /** The project's db (`server.dbs[0]`, any `defineDb` handle); `null` = no database → `auth.*` throws a clear error. */
24
- db: { readonly $auth: AuthBinding, readonly $schema: string } | null
25
- /** HMAC key for OAuth nonces (runner-wide secret). */
26
- secret: string
27
- /** `app.json` `auth.providers` (the compiler embeds them; the runner reads them off the manifest). */
28
- providers: AuthProvider[]
36
+ /** The project's db with sign-in (`defineDb(...).withAuth(...)`); `null`, or a db without it = no auth:
37
+ * requests carry no session and `db.auth` does not exist. */
38
+ db: { readonly $auth: AuthBinding | null } | null
39
+ /** The app's name in the sign-in mail (`app.json`). */
29
40
  appName?: string
30
- /** `<publicUrl>/p/<key>` — OAuth start links are minted under it; absent = OAuth unavailable. */
31
- publicBase?: string
32
- /** Nonce scope (the runner's project key): a nonce minted for one project can't bind a session of another. */
33
- scope?: string
34
41
  /** Sends the sign-in code (the platform mailer). Absent = codes are only echoed to the console. */
35
42
  mail?: (msg: AuthMail) => Promise<void>
36
43
  /** Echo every code to the server console (the draft slot: the developer is the only user). */
37
44
  echoCodes?: boolean
38
- /** Verifies a NATIVE-SDK credential with the provider (runner-side, knows the platform's app ids) → profile.
39
- * Absent = `auth.oauth.signIn` is unavailable. Throw `ApiError` for a bad credential. */
40
- verifyOAuth?: (provider: Exclude<AuthProvider, "email">, credential: OAuthCredential) => Promise<OAuthProfile>
41
45
  /** Server console sink (default console). */
42
46
  log?: (level: "warn" | "info", text: string) => void
43
47
  now?: () => number
48
+ /**
49
+ * LOCAL DEVELOPMENT ONLY (`lecodes dev`): a code `signInWithCode` accepts for ANY email without a prior
50
+ * `sendCode` — the test sign-in. The platform never sets it; the host that does must be the developer's own machine.
51
+ */
52
+ devCode?: string
44
53
  }
45
54
 
46
55
  const CODE_TTL_MS = 10 * 60_000
47
56
  const CODE_MAX_ATTEMPTS = 5
48
57
  const SEND_MIN_GAP_MS = 30_000
49
58
  const SEND_MAX_PER_10MIN = 3
50
- const NONCE_TTL_MS = 10 * 60_000
59
+ const PASSWORD_MIN = 8
60
+ const PASSWORD_MAX = 128
61
+ const PASSWORD_FAILS_PER_LOGIN = 10
62
+ const PASSWORD_FAILS_PER_IP = 50
51
63
  const CACHE_TTL_MS = 60_000
52
64
  const SEEN_UPDATE_MS = 60_000
65
+ const SCRYPT_N = 16384
53
66
 
54
67
  const enc = new TextEncoder()
55
68
  const b64url = (bytes: ArrayBuffer | Uint8Array): string => {
@@ -63,20 +76,24 @@ export const sha256 = async (text: string): Promise<string> => hex(await crypto.
63
76
  const randomToken = (): string => b64url(crypto.getRandomValues(new Uint8Array(32)))
64
77
  const randomCode = (): string => String(crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000).padStart(6, "0")
65
78
 
66
- const hmac = async (secret: string, text: string): Promise<string> => {
67
- const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"])
68
- return b64url(await crypto.subtle.sign("HMAC", key, enc.encode(text)))
79
+ // ── passwords: scrypt (node:crypto — runs under Bun and Node alike), `scrypt$N$salt$hash` ──
80
+ const scrypt = (password: string, salt: Buffer, n: number): Promise<Buffer> =>
81
+ new Promise((res, rej) => scryptCb(password.normalize("NFKC"), salt, 32, { N: n, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }, (e, k) => (e ? rej(e) : res(k as Buffer))))
82
+ export const hashPassword = async (password: string): Promise<string> => {
83
+ const salt = randomBytes(16)
84
+ return `scrypt$${SCRYPT_N}$${salt.toString("base64url")}$${(await scrypt(password, salt, SCRYPT_N)).toString("base64url")}`
85
+ }
86
+ export const verifyPassword = async (password: string, stored: string): Promise<boolean> => {
87
+ const [algo, n, salt, hash] = stored.split("$")
88
+ if (algo !== "scrypt" || !n || !salt || !hash) return false
89
+ const expected = Buffer.from(hash, "base64url")
90
+ const actual = await scrypt(password, Buffer.from(salt, "base64url"), Number(n))
91
+ return actual.length === expected.length && timingSafeEqual(actual, expected)
69
92
  }
70
- /** OAuth nonce: `<sessionId>.<expiresAt>.<hmac>` — stateless, bound to a session (and a `scope`: the project), short-lived. */
71
- export const signNonce = async (secret: string, sessionId: number, expiresAt: number, scope = ""): Promise<string> =>
72
- `${sessionId}.${expiresAt}.${await hmac(secret, `${scope}|${sessionId}.${expiresAt}`)}`
73
- export const verifyNonce = async (secret: string, nonce: string, now = Date.now(), scope = ""): Promise<{ sessionId: number } | null> => {
74
- const [sid, exp, sig] = nonce.split(".")
75
- const sessionId = Number(sid), expiresAt = Number(exp)
76
- if (!Number.isInteger(sessionId) || !Number.isFinite(expiresAt) || !sig) return null
77
- if (expiresAt < now) return null
78
- if (sig !== await hmac(secret, `${scope}|${sid}.${exp}`)) return null
79
- return { sessionId }
93
+ const checkPassword = (password: unknown): string => {
94
+ if (typeof password !== "string" || password.length < PASSWORD_MIN) throw new ApiError(400, `Password must be at least ${PASSWORD_MIN} characters`)
95
+ if (password.length > PASSWORD_MAX) throw new ApiError(400, `Password must be at most ${PASSWORD_MAX} characters`)
96
+ return password
80
97
  }
81
98
 
82
99
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
@@ -86,66 +103,71 @@ const normalizeEmail = (email: unknown): string => {
86
103
  return e
87
104
  }
88
105
 
89
- /** Built-in user shape from a row (custom scalar fields ride along at runtime). */
90
- const toUser = (row: any): AuthUser => row as AuthUser
106
+ const LOGIN_RE = /^[\p{L}\p{N}_.-]{3,32}$/u
107
+ /** What a person signs in by: an address (it has an `@`) or a name — the `email` / `login` identity. */
108
+ const parseLogin = (login: unknown): { provider: "email" | "login", subject: string } => {
109
+ const l = typeof login === "string" ? login.trim() : ""
110
+ if (l.includes("@")) return { provider: "email", subject: normalizeEmail(l) }
111
+ if (!LOGIN_RE.test(l)) throw new ApiError(400, "Login must be 3–32 letters, digits, dots, dashes or underscores")
112
+ return { provider: "login", subject: l.toLowerCase() }
113
+ }
114
+
115
+ const identityKey = (provider: IdentityProvider, subject: string) => `${provider}:${subject}`
116
+
117
+ /** A sliding-window counter per key (in-worker; the persistent limits live on the rows). */
118
+ const window = (map: Map<string, number[]>, key: string, now: number, span: number): number[] => {
119
+ const list = (map.get(key) ?? []).filter(x => now - x < span)
120
+ map.set(key, list)
121
+ return list
122
+ }
91
123
 
92
124
  export type AuthHost = {
93
- /** Resolve the bearer token of `ctx` into `ctx.auth` (minting a guest session when needed). No db → leaves it undefined. */
125
+ /** Does the project's db have sign-in? Without it nothing is resolved and `db.auth` does not exist. */
126
+ readonly enabled: boolean
127
+ /** Resolve the bearer token of `ctx` into `ctx.auth` (minting a guest session when needed). */
94
128
  resolve(ctx: RequestContext): Promise<void>
95
129
  ops: AuthOps
96
- /** Install `ops` for the bundle's `auth` global (`globalThis.__lecodesAuth`). */
130
+ /** Install `ops` for the bundle's `db.auth` (`globalThis.__lecodesAuth`). */
97
131
  install(): void
98
132
  /**
99
- * OAuth callback: bind the session named by `nonce` to the user identified by `profile` (existing
100
- * `<provider>Id`, else a verified-email match, else a new user). Runs in the worker; the runner's
101
- * broker route calls it with what the provider returned.
133
+ * LOCAL DEVELOPMENT ONLY (needs `devCode`): a fresh session already signed in as `email` (the user and its
134
+ * verified email identity are created when missing). The token is what a test host seeds the app's transport
135
+ * with (`localStorage["lecodes.session:<url>"]`) so a scenario starts past the sign-in screen.
102
136
  */
103
- oauthComplete(provider: Exclude<AuthProvider, "email">, nonce: string, profile: OAuthProfile): Promise<{ userId: number }>
104
- /** Test hook / diagnostics: forget cached sessions and users. */
137
+ devSignIn(email: string): Promise<{ token: string, userId: number }>
138
+ /** Test hook / diagnostics: forget cached sessions. */
105
139
  clearCache(): void
106
140
  }
107
141
 
108
142
  export const createAuthHost = (cfg: AuthHostConfig): AuthHost => {
109
- const db = cfg.db as any
110
- const binding = cfg.db?.$auth ?? null
111
- const hasUser = !!binding?.user
143
+ const tables = cfg.db?.$auth ?? null
144
+ const users = tables?.user, sessions = tables?.session, identities = tables?.identity
112
145
  const now = cfg.now ?? (() => Date.now())
113
146
  const log = cfg.log ?? ((level, text) => (level === "warn" ? console.warn(text) : console.info(text)))
114
- const providers = new Set(cfg.providers)
115
147
 
116
- // token → session (60 s), user id → user row (60 s). Cleared on every mutation — correctness over reuse.
117
- const sessionCache = new Map<string, { sessionId: number, userId: number | null, at: number, seenAt: number }>()
118
- const userCache = new Map<number, { user: AuthUser | null, at: number }>()
119
- const clearCache = () => { sessionCache.clear(); userCache.clear() }
148
+ // token → session (60 s). Cleared on every mutation — correctness over reuse.
149
+ const sessionCache = new Map<string, { sessionId: number, userId: number | null, at: number }>()
150
+ const clearCache = () => { sessionCache.clear() }
120
151
 
121
- const requireDb = () => {
122
- if (!db) throw new Error("auth: this project has no database — sessions need a defineDb()")
123
- return db
124
- }
125
- const requireUserModel = (what: string) => {
126
- if (!hasUser) throw new Error(`${what} needs a user model: export const User = auth.model.user({...}) in your schema`)
152
+ const requireAuth = () => {
153
+ if (!tables) throw new Error("db.auth: this project's database has no sign-in — defineDb({...}).withAuth({ model: \"<your user model>\" })")
127
154
  }
128
155
  const stateOf = (ctx: RequestContext): AuthState => {
129
156
  const s = ctx.auth as AuthState | undefined
130
- if (!s) throw new Error("auth: this project has no database — sessions need a defineDb()")
157
+ if (!s) throw new Error("db.auth: the request carries no session")
131
158
  return s
132
159
  }
133
-
134
- const sessionSelect = () => (hasUser ? { id: true, revoked: true, lastSeenAt: true, user: { id: true } } : { id: true, revoked: true, lastSeenAt: true })
135
- const loadUser = async (userId: number | null): Promise<AuthUser | null> => {
136
- if (userId === null || !hasUser) return null
137
- const c = userCache.get(userId)
138
- if (c && now() - c.at < CACHE_TTL_MS) return c.user
139
- const row = await db.user.where({ id: userId }).first()
140
- const user = row ? toUser(row) : null
141
- userCache.set(userId, { user, at: now() })
142
- return user
160
+ const requireSignedIn = (ctx: RequestContext): number => {
161
+ const id = stateOf(ctx).userId
162
+ if (id === null) throw new ApiError(401, "Sign in required")
163
+ return id
143
164
  }
165
+
144
166
  const mint = async (ctx: RequestContext): Promise<AuthState> => {
145
167
  const token = randomToken()
146
- const { id } = await db.session.insert({ tokenHash: await sha256(token) })
147
- const state: AuthState = { sessionId: id, userId: null, user: null, issuedToken: token }
148
- sessionCache.set(token, { sessionId: id, userId: null, at: now(), seenAt: now() })
168
+ const { id } = await sessions.insert({ tokenHash: await sha256(token) })
169
+ const state: AuthState = { sessionId: id, userId: null, issuedToken: token }
170
+ sessionCache.set(token, { sessionId: id, userId: null, at: now() })
149
171
  ctx.auth = state
150
172
  ctx.sessionToken = token
151
173
  return state
@@ -153,59 +175,122 @@ export const createAuthHost = (cfg: AuthHostConfig): AuthHost => {
153
175
  const rotate = async (ctx: RequestContext): Promise<void> => {
154
176
  const s = stateOf(ctx)
155
177
  const token = randomToken()
156
- await db.session.update(s.sessionId, { tokenHash: await sha256(token) })
178
+ await sessions.update(s.sessionId, { tokenHash: await sha256(token) })
157
179
  clearCache()
158
180
  s.issuedToken = token
159
181
  ctx.sessionToken = token
160
182
  }
161
- const bind = async (ctx: RequestContext, userId: number | null, opts: { rotate: boolean }): Promise<AuthUser | null> => {
183
+ const bind = async (ctx: RequestContext, userId: number | null, opts: { rotate: boolean }): Promise<void> => {
162
184
  const s = stateOf(ctx)
163
- await db.session.update(s.sessionId, { user: userId === null ? null : { $connect: { id: userId } } })
185
+ await sessions.update(s.sessionId, { user: userId === null ? null : { $connect: { id: userId } } })
164
186
  clearCache()
165
187
  s.userId = userId
166
- s.user = await loadUser(userId)
167
188
  if (opts.rotate) await rotate(ctx)
168
- return s.user
169
189
  }
170
190
 
171
191
  const resolve = async (ctx: RequestContext): Promise<void> => {
172
- if (!db) return
192
+ if (!tables) return
173
193
  const token = ctx.sessionToken
174
194
  if (token) {
175
195
  const c = sessionCache.get(token)
176
196
  if (c && now() - c.at < CACHE_TTL_MS) {
177
- ctx.auth = { sessionId: c.sessionId, userId: c.userId, user: await loadUser(c.userId) }
197
+ ctx.auth = { sessionId: c.sessionId, userId: c.userId }
178
198
  return
179
199
  }
180
- const row = await db.session.where({ tokenHash: await sha256(token) }).select(sessionSelect()).first()
200
+ const row = await sessions.where({ tokenHash: await sha256(token) }).select({ id: true, revoked: true, lastSeenAt: true, user: { id: true } }).first()
181
201
  if (row && !row.revoked) {
182
- const userId: number | null = hasUser && row.user ? row.user.id : null
183
- if (now() - (row.lastSeenAt ?? 0) > SEEN_UPDATE_MS) db.session.update(row.id, { lastSeenAt: now() }).then(undefined, () => {})
184
- sessionCache.set(token, { sessionId: row.id, userId, at: now(), seenAt: now() })
185
- ctx.auth = { sessionId: row.id, userId, user: await loadUser(userId) }
202
+ const userId: number | null = row.user ? row.user.id : null
203
+ if (now() - (row.lastSeenAt ?? 0) > SEEN_UPDATE_MS) sessions.update(row.id, { lastSeenAt: now() }).then(undefined, () => {})
204
+ sessionCache.set(token, { sessionId: row.id, userId, at: now() })
205
+ ctx.auth = { sessionId: row.id, userId }
186
206
  return
187
207
  }
188
208
  }
189
209
  await mint(ctx)
190
210
  }
191
211
 
212
+ // ── identities ──
213
+ type IdentityRow = { id: number, hash: string | null, verifiedAt: number | null, user: { id: number } }
214
+ const findIdentity = (provider: IdentityProvider, subject: string): Promise<IdentityRow | null> =>
215
+ identities.where({ key: identityKey(provider, subject) }).select({ id: true, hash: true, verifiedAt: true, user: { id: true } }).first()
216
+ const createIdentity = (userId: number, provider: IdentityProvider, subject: string, extra: Record<string, unknown> = {}) =>
217
+ identities.insert({ user: { id: userId }, provider, subject, key: identityKey(provider, subject), ...extra })
218
+ /** The project's row of a new person: empty (every field has a default — `withAuth` checked), or what a sign-up handed over. */
219
+ const createUser = async (data?: Record<string, unknown>): Promise<number> => (await users.insert(data ?? {})).id
220
+ /**
221
+ * The email was just proven for `userId` (a code): mark the `email` identity
222
+ * verified — and drop a password that was set BEFORE the address was confirmed (it proved nothing).
223
+ * `create` = make the identity when missing.
224
+ */
225
+ const proveEmail = async (userId: number, email: string, create: boolean): Promise<void> => {
226
+ const ident = await findIdentity("email", email)
227
+ if (ident) {
228
+ if (ident.user.id !== userId) throw new ApiError(409, "This email belongs to another account")
229
+ if (!ident.verifiedAt) await identities.update(ident.id, { verifiedAt: now(), hash: null })
230
+ } else if (create) {
231
+ await createIdentity(userId, "email", email, { verifiedAt: now() })
232
+ }
233
+ }
234
+
235
+ // ── passwords ──
236
+ const passwordFails = new Map<string, number[]>()
237
+ const signUpWithPassword = async (ctx: RequestContext, rawLogin: string, rawPassword: string, data?: Record<string, unknown>): Promise<void> => {
238
+ requireAuth()
239
+ const { provider, subject } = parseLogin(rawLogin)
240
+ const password = checkPassword(rawPassword)
241
+ if (await findIdentity(provider, subject)) throw new ApiError(409, provider === "email" ? "An account with this email already exists" : "This login is taken")
242
+ const userId = await createUser(data)
243
+ await createIdentity(userId, provider, subject, { hash: await hashPassword(password) })
244
+ await bind(ctx, userId, { rotate: true })
245
+ }
246
+ const signInWithPassword = async (ctx: RequestContext, rawLogin: string, rawPassword: string): Promise<void> => {
247
+ requireAuth()
248
+ // a login that could not exist fails like a wrong password — one message, nothing to learn from it
249
+ let who: { provider: "email" | "login", subject: string } | null = null
250
+ try { who = parseLogin(rawLogin) } catch { /* below */ }
251
+ const t = now()
252
+ const byLogin = window(passwordFails, `l:${who ? identityKey(who.provider, who.subject) : ""}`, t, 10 * 60_000)
253
+ const byIp = ctx.ip ? window(passwordFails, `ip:${ctx.ip}`, t, 10 * 60_000) : []
254
+ if (byLogin.length >= PASSWORD_FAILS_PER_LOGIN || byIp.length >= PASSWORD_FAILS_PER_IP) throw new ApiError(429, "Too many attempts — try again later")
255
+ const ident = who ? await findIdentity(who.provider, who.subject) : null
256
+ const ok = !!ident?.hash && typeof rawPassword === "string" && await verifyPassword(rawPassword, ident.hash)
257
+ if (!ok) {
258
+ byLogin.push(t); byIp.push(t)
259
+ throw new ApiError(401, "Invalid login or password")
260
+ }
261
+ await bind(ctx, ident!.user.id, { rotate: true })
262
+ }
263
+ const setPassword = async (ctx: RequestContext, rawPassword: string): Promise<void> => {
264
+ requireAuth()
265
+ const userId = requireSignedIn(ctx)
266
+ const password = checkPassword(rawPassword)
267
+ // the password belongs to what the person types to sign in: their email and / or their login
268
+ const mine: { id: number, provider: string }[] = await identities.where({ user: { id: userId } }).select({ id: true, provider: true })
269
+ const typed = mine.filter(i => i.provider === "email" || i.provider === "login")
270
+ if (!typed.length) throw new ApiError(400, "Your account has no email or login to sign in with a password — sign in with a code first")
271
+ const hash = await hashPassword(password)
272
+ for (const i of typed) await identities.update(i.id, { hash })
273
+ // a password change signs every OTHER device out
274
+ const s = stateOf(ctx)
275
+ await sessions.where({ user: { id: userId }, id: { $ne: s.sessionId } }).updateMany({ revoked: true })
276
+ clearCache()
277
+ }
278
+
192
279
  // ── email codes ──
193
280
  const sends = new Map<string, number[]>() // email → send timestamps (per worker; attempts persist on the row)
194
281
  const codeHash = (sessionId: number, code: string) => sha256(`${sessionId}:${code}`)
195
282
 
196
283
  const sendCode = async (ctx: RequestContext, rawEmail: string): Promise<void> => {
197
- requireDb()
198
- if (!providers.has("email")) throw new Error(`auth.email is not enabled — add "email" to auth.providers in app.json`)
199
- requireUserModel("auth.email")
284
+ requireAuth()
200
285
  const s = stateOf(ctx)
201
286
  const email = normalizeEmail(rawEmail)
202
287
  const t = now()
203
- const recent = (sends.get(email) ?? []).filter(x => t - x < 10 * 60_000)
288
+ const recent = window(sends, email, t, 10 * 60_000)
204
289
  if (recent.length && t - recent[recent.length - 1] < SEND_MIN_GAP_MS) throw new ApiError(429, `Try again in ${Math.ceil((SEND_MIN_GAP_MS - (t - recent[recent.length - 1])) / 1000)} s`)
205
290
  if (recent.length >= SEND_MAX_PER_10MIN) throw new ApiError(429, "Too many codes requested — try again later")
206
291
  const code = randomCode()
207
- await db.session.update(s.sessionId, { pendingEmail: email, codeHash: await codeHash(s.sessionId, code), codeExpires: t + CODE_TTL_MS, codeAttempts: 0 })
208
- recent.push(t); sends.set(email, recent)
292
+ await sessions.update(s.sessionId, { pendingEmail: email, codeHash: await codeHash(s.sessionId, code), codeExpires: t + CODE_TTL_MS, codeAttempts: 0 })
293
+ recent.push(t)
209
294
  const appName = cfg.appName || "the app"
210
295
  if (cfg.mail) {
211
296
  try { await cfg.mail({ to: email, code, appName }) }
@@ -214,105 +299,90 @@ export const createAuthHost = (cfg: AuthHostConfig): AuthHost => {
214
299
  if (!cfg.mail || cfg.echoCodes) log("info", `[auth] sign-in code for ${email}: ${code}${cfg.mail ? "" : " (no mailer configured — the code was not emailed)"}`)
215
300
  }
216
301
 
217
- const verify = async (ctx: RequestContext, rawEmail: string, code: string): Promise<AuthUser> => {
218
- requireDb()
219
- requireUserModel("auth.email")
220
- const s = stateOf(ctx)
221
- const email = normalizeEmail(rawEmail)
222
- const row = await db.session.where({ id: s.sessionId }).select({ pendingEmail: true, codeHash: true, codeExpires: true, codeAttempts: true }).first()
223
- if (!row?.codeHash || row.pendingEmail !== email || !row.codeExpires || row.codeExpires < now()) throw new ApiError(400, "Code expired — request a new one")
224
- if (row.codeAttempts >= CODE_MAX_ATTEMPTS) throw new ApiError(429, "Too many attempts — request a new code")
225
- if (typeof code !== "string" || await codeHash(s.sessionId, code.trim()) !== row.codeHash) {
226
- await db.session.update(s.sessionId, { codeAttempts: { $increment: 1 } })
227
- throw new ApiError(400, "Invalid code")
228
- }
229
- const existing = await db.user.where({ email }).first()
230
- const userId: number = existing ? existing.id : (await db.user.insert({ email })).id
231
- await db.session.update(s.sessionId, { pendingEmail: null, codeHash: null, codeExpires: null, codeAttempts: 0 })
232
- const user = await bind(ctx, userId, { rotate: true })
233
- return user!
302
+ /** The user an email belongs to, made when nobody has it yet; the address is proven either way. */
303
+ const userOfProvenEmail = async (email: string): Promise<number> => {
304
+ const ident = await findIdentity("email", email)
305
+ const userId = ident ? ident.user.id : await createUser()
306
+ await proveEmail(userId, email, true)
307
+ return userId
234
308
  }
235
309
 
236
- // ── oauth ──
237
- const oauthUrl = async (ctx: RequestContext, provider: Exclude<AuthProvider, "email">): Promise<string> => {
238
- requireDb()
239
- if (!providers.has(provider)) throw new Error(`auth.oauth: "${provider}" is not enabled — add it to auth.providers in app.json`)
240
- requireUserModel("auth.oauth")
241
- if (!cfg.publicBase) throw new Error("auth.oauth: not available in this environment (no public runner URL)")
310
+ const signInWithCode = async (ctx: RequestContext, rawEmail: string, code: string): Promise<void> => {
311
+ requireAuth()
242
312
  const s = stateOf(ctx)
243
- const nonce = await signNonce(cfg.secret, s.sessionId, now() + NONCE_TTL_MS, cfg.scope)
244
- return `${cfg.publicBase}/auth/${provider}/start?s=${encodeURIComponent(nonce)}`
245
- }
246
-
247
- /** The user a provider identity maps to: by `<provider>Id`, else a verified-email match (linked), else created. */
248
- const userForProfile = async (provider: Exclude<AuthProvider, "email">, profile: OAuthProfile): Promise<number> => {
249
- const idField = `${provider}Id`
250
- if (!profile.sub) throw new ApiError(502, `${provider} returned no account id`)
251
- let user = await db.user.where({ [idField]: profile.sub }).first()
252
- if (!user && profile.email && profile.emailVerified !== false) {
253
- const byEmail = await db.user.where({ email: profile.email.toLowerCase() }).first()
254
- if (byEmail) { await db.user.update(byEmail.id, { [idField]: profile.sub }); user = { ...byEmail, [idField]: profile.sub } }
255
- }
256
- if (!user) {
257
- const data: Record<string, unknown> = { [idField]: profile.sub }
258
- if (profile.email && profile.emailVerified !== false) data.email = profile.email.toLowerCase()
259
- if (profile.name) data.name = profile.name
260
- if (profile.avatar) data.avatar = profile.avatar
261
- const { id } = await db.user.insert(data)
262
- user = { id }
313
+ const email = normalizeEmail(rawEmail)
314
+ // the local dev code skips the sent-code check entirely (no sendCode needed) — never set on the platform
315
+ const isDevCode = !!cfg.devCode && typeof code === "string" && code.trim() === cfg.devCode
316
+ if (!isDevCode) {
317
+ const row = await sessions.where({ id: s.sessionId }).select({ pendingEmail: true, codeHash: true, codeExpires: true, codeAttempts: true }).first()
318
+ if (!row?.codeHash || row.pendingEmail !== email || !row.codeExpires || row.codeExpires < now()) throw new ApiError(400, "Code expired — request a new one")
319
+ if (row.codeAttempts >= CODE_MAX_ATTEMPTS) throw new ApiError(429, "Too many attempts — request a new code")
320
+ if (typeof code !== "string" || await codeHash(s.sessionId, code.trim()) !== row.codeHash) {
321
+ await sessions.update(s.sessionId, { codeAttempts: { $increment: 1 } })
322
+ throw new ApiError(400, "Invalid code")
323
+ }
263
324
  }
264
- return user.id
265
- }
266
-
267
- const oauthComplete = async (provider: Exclude<AuthProvider, "email">, nonce: string, profile: OAuthProfile): Promise<{ userId: number }> => {
268
- requireDb()
269
- requireUserModel("auth.oauth")
270
- const n = await verifyNonce(cfg.secret, nonce, now(), cfg.scope)
271
- if (!n) throw new ApiError(400, "This sign-in link has expired — start again from the app")
272
- const session = await db.session.where({ id: n.sessionId }).select({ id: true, revoked: true, user: { id: true } }).first()
273
- if (!session || session.revoked) throw new ApiError(400, "This sign-in link is no longer valid — start again from the app")
274
- const userId = await userForProfile(provider, profile)
275
- // A session already signed in as someone else is not re-bound by a stray link (fixation guard).
276
- if (session.user && session.user.id !== userId) throw new ApiError(409, "This device is already signed in as another user — sign out first")
277
- if (!session.user) await db.session.update(session.id, { user: { $connect: { id: userId } } })
278
- clearCache()
279
- return { userId }
325
+ const userId = await userOfProvenEmail(email)
326
+ await sessions.update(s.sessionId, { pendingEmail: null, codeHash: null, codeExpires: null, codeAttempts: 0 })
327
+ await bind(ctx, userId, { rotate: true })
280
328
  }
281
329
 
282
- // Native-SDK credential (client plugin OAuth.signIn) → verified with the provider → bound to THIS session, rotated
283
- // like any in-band sign-in (the same client receives the new token).
284
- const oauthSignIn = async (ctx: RequestContext, provider: Exclude<AuthProvider, "email">, credential: OAuthCredential): Promise<AuthUser> => {
285
- requireDb()
286
- if (!providers.has(provider)) throw new Error(`auth.oauth: "${provider}" is not enabled — add it to auth.providers in app.json`)
287
- requireUserModel("auth.oauth")
288
- if (!cfg.verifyOAuth) throw new Error("auth.oauth.signIn: not available in this environment (no provider verification configured)")
289
- if (!credential || typeof credential !== "object" || (typeof credential.accessToken !== "string" && typeof credential.idToken !== "string")) throw new ApiError(400, "A provider credential (accessToken or idToken) is required")
290
- const profile = await cfg.verifyOAuth(provider, credential)
291
- // Apple only sends the name on the first sign-in — the app forwards what the sheet returned
292
- if (!profile.name && typeof credential.name === "string" && credential.name.trim()) profile.name = credential.name.trim()
293
- const userId = await userForProfile(provider, profile)
294
- return (await bind(ctx, userId, { rotate: true }))!
295
- }
330
+ /**
331
+ * A query of the platform's model handed to project code: every write through it drops the cached
332
+ * sessions, so a device revoked by `db.auth.sessions.where(...).updateMany({ revoked: true })` is out
333
+ * at its next request, not when the cache expires.
334
+ */
335
+ const WRITES = new Set(["update", "updateMany", "delete", "deleteMany"])
336
+ const guarded = (q: any): any => new Proxy(q, {
337
+ get: (target, prop) => {
338
+ const value = target[prop]
339
+ if (typeof value !== "function" || prop === "then") return value
340
+ return (...args: unknown[]) => {
341
+ const out = value.apply(target, args)
342
+ if (!out || typeof out !== "object") return out
343
+ if (!WRITES.has(prop as string)) return typeof out.where === "function" ? guarded(out) : out
344
+ return { ...out, then: (ok?: any, fail?: any) => out.then((v: unknown) => { clearCache(); return v }).then(ok, fail) }
345
+ }
346
+ },
347
+ })
296
348
 
297
349
  const ops: AuthOps = {
298
- email: { sendCode, verify },
299
- oauth: { url: oauthUrl, signIn: oauthSignIn },
350
+ signUpWithPassword, signInWithPassword, setPassword,
351
+ sendCode, signInWithCode,
300
352
  signIn: async (ctx, userId) => {
301
- requireDb(); requireUserModel("auth.signIn")
302
- const row = await db.user.where({ id: userId }).first()
303
- if (!row) throw new ApiError(404, "No such user")
304
- return (await bind(ctx, userId, { rotate: true }))!
353
+ requireAuth()
354
+ if (!await users.where({ id: userId }).select({ id: true }).first()) throw new ApiError(404, "No such user")
355
+ await bind(ctx, userId, { rotate: true })
305
356
  },
306
- signOut: async (ctx) => { requireDb(); await bind(ctx, null, { rotate: true }) },
357
+ signOut: async (ctx) => { requireAuth(); await bind(ctx, null, { rotate: true }) },
307
358
  sessions: (ctx) => {
308
- requireDb()
359
+ requireAuth()
309
360
  const s = stateOf(ctx)
310
- return s.userId !== null && hasUser ? db.session.where({ user: { id: s.userId } }) : db.session.where({ id: s.sessionId })
361
+ const q = s.userId !== null ? sessions.where({ user: { id: s.userId } }) : sessions.where({ id: s.sessionId })
362
+ return guarded(q.select({ id: true, createdAt: true, lastSeenAt: true, revoked: true }))
311
363
  },
364
+ identities: (ctx) => {
365
+ requireAuth()
366
+ const s = stateOf(ctx)
367
+ // the hash stays out of the projection; a guest sees an empty list
368
+ return identities.where({ user: { id: s.userId ?? 0 } })
369
+ .select({ id: true, provider: true, subject: true, verifiedAt: true, createdAt: true })
370
+ },
371
+ }
372
+
373
+ const devSignIn = async (rawEmail: string): Promise<{ token: string, userId: number }> => {
374
+ if (!cfg.devCode) throw new Error("auth.devSignIn: local development only (no devCode configured)")
375
+ requireAuth()
376
+ const userId = await userOfProvenEmail(normalizeEmail(rawEmail))
377
+ const token = randomToken()
378
+ await sessions.insert({ tokenHash: await sha256(token), user: { id: userId } })
379
+ clearCache()
380
+ return { token, userId }
312
381
  }
313
382
 
314
383
  return {
315
- resolve, ops, oauthComplete, clearCache,
384
+ enabled: !!tables,
385
+ resolve, ops, devSignIn, clearCache,
316
386
  install: () => { (globalThis as any).__lecodesAuth = ops },
317
387
  }
318
388
  }