lecodes-sdk 2.0.3 → 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.
- package/README.md +104 -76
- package/dist/global.d.ts +3 -5
- package/dist/host.d.ts +3 -0
- package/dist/types/inject.d.ts +4 -4
- package/dist/types/net/codec.d.ts +3 -2
- package/dist/types/net/core.d.ts +10 -1
- package/dist/types/net/index.d.ts +22 -5
- package/dist/types/net/replication.d.ts +23 -2
- package/dist/types/runtime/device.d.ts +7 -0
- package/dist/types/runtime/rpc.d.ts +11 -17
- package/dist/types/runtime/wire.d.ts +53 -0
- package/dist/types/server/auth/api.d.ts +42 -0
- package/dist/types/server/auth/appConfig.d.ts +1 -5
- package/dist/types/server/auth/models.d.ts +119 -70
- package/dist/types/server/auth/types.d.ts +19 -43
- package/dist/types/server/channel.d.ts +57 -19
- package/dist/types/server/context.d.ts +2 -2
- package/dist/types/server/db/defineDb.d.ts +10 -0
- package/dist/types/server/db/index.d.ts +1 -1
- package/dist/types/server/db/types.d.ts +76 -6
- package/dist/types/server/inject.d.ts +0 -1
- package/dist/types/ui/UINode.d.ts +19 -5
- package/dist/types/ui/UIScreen.d.ts +1 -0
- package/dist/types/ui/UITabs.d.ts +8 -6
- package/dist/types/ui/theme.d.ts +48 -13
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +4 -2
- package/prompts/README.md +1 -1
- package/prompts/design.md +19 -19
- package/prompts/dist/2d-game.md +45 -31
- package/prompts/dist/3d-app.md +45 -31
- package/prompts/dist/ar-app.md +45 -31
- package/prompts/dist/design.md +25 -24
- package/prompts/dist/ui-app.md +45 -31
- package/prompts/ui-design.md +6 -5
- package/prompts/ui.md +25 -22
- package/src/animate/tween/read.ts +146 -0
- package/src/bridges/device.d.ts +9 -0
- package/src/bridges/tree.d.ts +5 -0
- package/src/canvas/gen/cssColor.ts +1 -1
- package/src/canvas/gen/recorder.ts +1 -1
- package/src/canvas/gen/spec.ts +1 -1
- package/src/chisel.ts +1 -1
- package/src/compile/bundler.ts +6 -0
- package/src/compile/compileProject.ts +3 -1
- package/src/compile/index.ts +3 -1
- package/src/compile/serverSplit.ts +58 -11
- package/src/compile/serverTypes.ts +189 -8
- package/src/host.d.ts +3 -0
- package/src/inject.ts +7 -7
- package/src/net/codec.ts +19 -10
- package/src/net/core.ts +18 -5
- package/src/net/index.ts +30 -9
- package/src/net/replication.ts +63 -28
- package/src/runtime/device.ts +12 -0
- package/src/runtime/rpc.ts +101 -40
- package/src/runtime/wire.ts +35 -0
- package/src/server/auth/api.ts +94 -0
- package/src/server/auth/appConfig.ts +2 -3
- package/src/server/auth/host.ts +244 -174
- package/src/server/auth/models.ts +45 -62
- package/src/server/auth/types.ts +19 -34
- package/src/server/channel.ts +97 -29
- package/src/server/channelHub.ts +153 -0
- package/src/server/context.ts +2 -2
- package/src/server/db/defineDb.ts +96 -36
- package/src/server/db/index.ts +1 -1
- package/src/server/db/types.ts +76 -8
- package/src/server/host.ts +25 -10
- package/src/server/inject.ts +2 -2
- package/src/server/runtime.ts +34 -12
- package/src/ui/UINode.ts +22 -5
- package/src/ui/UIScreen.ts +5 -0
- package/src/ui/UITabs.ts +19 -17
- package/src/ui/styleColor.ts +10 -1
- package/src/ui/theme.ts +96 -41
- package/src/version.ts +1 -1
- package/tests/helpers/fakeTree.ts +1 -0
- package/dist/types/plugins/oauth.d.ts +0 -25
- package/dist/types/server/auth/global.d.ts +0 -56
- package/src/plugins/oauth.ts +0 -61
- package/src/server/auth/global.ts +0 -80
- package/tests/helpers/memoryMarci.ts +0 -124
package/src/server/auth/host.ts
CHANGED
|
@@ -1,55 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The HOST side of auth
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
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.
|
|
11
|
-
*
|
|
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 {
|
|
18
|
-
import type { AuthOps, AuthState
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
/**
|
|
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`
|
|
130
|
+
/** Install `ops` for the bundle's `db.auth` (`globalThis.__lecodesAuth`). */
|
|
97
131
|
install(): void
|
|
98
132
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
|
|
104
|
-
/** Test hook / diagnostics: forget cached sessions
|
|
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
|
|
110
|
-
const
|
|
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)
|
|
117
|
-
const sessionCache = new Map<string, { sessionId: number, userId: number | null, at: number
|
|
118
|
-
const
|
|
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
|
|
122
|
-
if (!
|
|
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:
|
|
157
|
+
if (!s) throw new Error("db.auth: the request carries no session")
|
|
131
158
|
return s
|
|
132
159
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
147
|
-
const state: AuthState = { sessionId: id, userId: null,
|
|
148
|
-
sessionCache.set(token, { sessionId: id, userId: null, at: 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
|
|
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<
|
|
183
|
+
const bind = async (ctx: RequestContext, userId: number | null, opts: { rotate: boolean }): Promise<void> => {
|
|
162
184
|
const s = stateOf(ctx)
|
|
163
|
-
await
|
|
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 (!
|
|
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
|
|
197
|
+
ctx.auth = { sessionId: c.sessionId, userId: c.userId }
|
|
178
198
|
return
|
|
179
199
|
}
|
|
180
|
-
const row = await
|
|
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 =
|
|
183
|
-
if (now() - (row.lastSeenAt ?? 0) > SEEN_UPDATE_MS)
|
|
184
|
-
sessionCache.set(token, { sessionId: row.id, userId, at: now()
|
|
185
|
-
ctx.auth = { sessionId: row.id, 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
|
-
|
|
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
|
|
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
|
|
208
|
-
recent.push(t)
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
299
|
-
|
|
350
|
+
signUpWithPassword, signInWithPassword, setPassword,
|
|
351
|
+
sendCode, signInWithCode,
|
|
300
352
|
signIn: async (ctx, userId) => {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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) => {
|
|
357
|
+
signOut: async (ctx) => { requireAuth(); await bind(ctx, null, { rotate: true }) },
|
|
307
358
|
sessions: (ctx) => {
|
|
308
|
-
|
|
359
|
+
requireAuth()
|
|
309
360
|
const s = stateOf(ctx)
|
|
310
|
-
|
|
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
|
-
|
|
384
|
+
enabled: !!tables,
|
|
385
|
+
resolve, ops, devSignIn, clearCache,
|
|
316
386
|
install: () => { (globalThis as any).__lecodesAuth = ops },
|
|
317
387
|
}
|
|
318
388
|
}
|