lecodes-cli 0.16.0 → 0.16.2

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 (38) hide show
  1. package/README.md +27 -4
  2. package/dist/index.js +170706 -72
  3. package/package.json +11 -6
  4. package/runtime/sdk/compile/bundler.ts +10 -2
  5. package/runtime/sdk/compile/compileProject.ts +10 -2
  6. package/runtime/sdk/compile/detectEntry.ts +5 -2
  7. package/runtime/sdk/compile/index.ts +17 -1
  8. package/runtime/sdk/compile/serverSplit.ts +233 -0
  9. package/runtime/sdk/compile/serverTypes.ts +227 -0
  10. package/runtime/sdk/gl/AnimationClip.ts +174 -0
  11. package/runtime/sdk/gl/Animator.ts +634 -0
  12. package/runtime/sdk/gl/CharacterController.ts +6 -1
  13. package/runtime/sdk/gl/IK.ts +170 -0
  14. package/runtime/sdk/gl/Material.ts +1 -1
  15. package/runtime/sdk/gl/ModelAnimation.ts +34 -3
  16. package/runtime/sdk/gl/Node.ts +21 -0
  17. package/runtime/sdk/inject.ts +10 -0
  18. package/runtime/sdk/plugins/oauth.ts +61 -0
  19. package/runtime/sdk/runtime/rpc.ts +218 -0
  20. package/runtime/sdk/server/auth/appConfig.ts +12 -0
  21. package/runtime/sdk/server/auth/global.ts +80 -0
  22. package/runtime/sdk/server/auth/host.ts +318 -0
  23. package/runtime/sdk/server/auth/models.ts +83 -0
  24. package/runtime/sdk/server/auth/types.ts +50 -0
  25. package/runtime/sdk/server/channel.ts +56 -0
  26. package/runtime/sdk/server/context.ts +36 -0
  27. package/runtime/sdk/server/db/defineDb.ts +237 -0
  28. package/runtime/sdk/server/db/fields.ts +132 -0
  29. package/runtime/sdk/server/db/httpTransport.ts +93 -0
  30. package/runtime/sdk/server/db/index.ts +7 -0
  31. package/runtime/sdk/server/db/marci/query.ts +412 -0
  32. package/runtime/sdk/server/db/types.ts +202 -0
  33. package/runtime/sdk/server/errors.ts +12 -0
  34. package/runtime/sdk/server/host.ts +74 -0
  35. package/runtime/sdk/server/inject.ts +13 -0
  36. package/runtime/sdk/server/runtime.ts +133 -0
  37. package/runtime/sdk/server/validate.ts +87 -0
  38. package/runtime/sdk-types.json +1 -1
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The `auth` server global (docs/backend-plan.md §3.4) — the BUNDLE side. State (`auth.user`,
3
+ * `auth.session`) is read from the current request, which the host resolved before the endpoint ran;
4
+ * operations (`auth.email.*`, `auth.oauth.url`, `signIn/signOut`, `sessions`) forward to the host's
5
+ * `AuthOps` on `globalThis.__lecodesAuth` (a seam: the compiled bundle carries its own SDK copy). Nothing
6
+ * here touches the db or a secret; `auth.model.*` are pure schema factories.
7
+ */
8
+
9
+ import { currentRequest, type RequestContext } from "../context"
10
+ import type { Model, Query } from "../db/types"
11
+ import { ApiError } from "../errors"
12
+ import { authSessionModel, authUserModel, type AuthProvider, type SessionFields, type UserFields } from "./models"
13
+ import type { AuthOps, AuthState, AuthUser, OAuthCredential } from "./types"
14
+
15
+ const seam = globalThis as unknown as { __lecodesAuth?: AuthOps }
16
+ const ops = (): AuthOps => {
17
+ const o = seam.__lecodesAuth
18
+ if (!o) throw new Error("auth: not available — the host did not install the auth runtime (globalThis.__lecodesAuth)")
19
+ return o
20
+ }
21
+ const ctx = (): RequestContext => {
22
+ const c = currentRequest()
23
+ if (!c) throw new Error("auth: no current request — auth.* is only available while handling an endpoint or a channel join")
24
+ return c
25
+ }
26
+ const state = (): AuthState => {
27
+ const c = ctx()
28
+ const s = c.auth as AuthState | undefined
29
+ if (!s) throw new Error("auth: this project has no database — sessions need a defineDb() (auth.model.* are optional, Session is added for you)")
30
+ return s
31
+ }
32
+
33
+ /** The schema of the two built-in models, for typing `auth.sessions` (developer extras aren't visible here). */
34
+ type AuthSchema = { User: Model<UserFields>, Session: Model<SessionFields> }
35
+
36
+ export const auth = {
37
+ /** The signed-in user (built-in fields; custom ones via `db.user`) or `null`. Request-scoped. */
38
+ get user(): AuthUser | null { return state().user },
39
+ /** The device session — exists from the first request, before any login. */
40
+ get session(): { readonly id: number } { return { id: state().sessionId } },
41
+ /**
42
+ * The signed-in user, or `throw new ApiError(401)`; with `match`, every given field must equal the user's
43
+ * (`{ role: "admin" }`, `{ plan: "pro" }`) or it's a 403.
44
+ */
45
+ requireUser(match?: Record<string, unknown>): AuthUser {
46
+ const u = state().user
47
+ if (!u) throw new ApiError(401, "Sign in required")
48
+ if (match) for (const [k, v] of Object.entries(match)) if ((u as Record<string, unknown>)[k] !== v) throw new ApiError(403, "Forbidden")
49
+ return u
50
+ },
51
+ email: {
52
+ /** Emails a 6-digit code to `email` (always resolves — no "email exists" oracle; rate-limited). Needs `"email"` in app.json providers. */
53
+ sendCode: (email: string): Promise<void> => ops().email.sendCode(ctx(), email),
54
+ /** The only email sign-in: checks the code sent to this device session, finds/creates the `User`, binds the session. */
55
+ verify: (email: string, code: string): Promise<AuthUser> => ops().email.verify(ctx(), email, code),
56
+ },
57
+ oauth: {
58
+ /** A one-time sign-in URL for the platform's OAuth broker (`"google" | "apple" | "yandex" | "vk"`), bound to this device session — open it in the browser. */
59
+ url: (provider: Exclude<AuthProvider, "email">): Promise<string> => ops().oauth.url(ctx(), provider),
60
+ /**
61
+ * Sign in with a credential a NATIVE provider SDK produced in the app (client `OAuth.signIn(provider)`):
62
+ * the platform verifies it with the provider, finds/links/creates the `User`, binds this session. The
63
+ * in-app counterpart of the browser broker — same accounts either way.
64
+ */
65
+ signIn: (provider: Exclude<AuthProvider, "email">, credential: OAuthCredential): Promise<AuthUser> => ops().oauth.signIn(ctx(), provider, credential),
66
+ },
67
+ /** Escape hatch for developer-verified identities (Telegram initData, SSO): binds this session to `userId`. */
68
+ signIn: (userId: number): Promise<AuthUser> => ops().signIn(ctx(), userId),
69
+ /** Unbinds the user; the device stays a guest with the same `session.id` (token rotated). */
70
+ signOut: (): Promise<void> => ops().signOut(ctx()),
71
+ /** The current user's sessions ("my devices"): a `db.session` query — `await`, `.select()`, `.where(...).updateMany({ revoked: true })`. */
72
+ get sessions(): Query<AuthSchema, "Session"> { return ops().sessions(ctx()) },
73
+ /** Schema-time factories — see ./models.ts. */
74
+ model: {
75
+ user: authUserModel,
76
+ session: authSessionModel,
77
+ },
78
+ }
79
+
80
+ export type { AuthUser }
@@ -0,0 +1,318 @@
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.
7
+ *
8
+ * Tokens: 32 random bytes (base64url) held by the client transport; only `sha256(token)` is stored.
9
+ * 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`.
12
+ */
13
+
14
+ import type { RequestContext } from "../context"
15
+ import type { AuthBinding } from "../db/types"
16
+ import { ApiError } from "../errors"
17
+ import type { AuthProvider } from "./models"
18
+ import type { AuthOps, AuthState, AuthUser, OAuthCredential, OAuthProfile } from "./types"
19
+
20
+ export type AuthMail = { to: string, code: string, appName: string }
21
+
22
+ 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[]
29
+ 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
+ /** Sends the sign-in code (the platform mailer). Absent = codes are only echoed to the console. */
35
+ mail?: (msg: AuthMail) => Promise<void>
36
+ /** Echo every code to the server console (the draft slot: the developer is the only user). */
37
+ 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
+ /** Server console sink (default console). */
42
+ log?: (level: "warn" | "info", text: string) => void
43
+ now?: () => number
44
+ }
45
+
46
+ const CODE_TTL_MS = 10 * 60_000
47
+ const CODE_MAX_ATTEMPTS = 5
48
+ const SEND_MIN_GAP_MS = 30_000
49
+ const SEND_MAX_PER_10MIN = 3
50
+ const NONCE_TTL_MS = 10 * 60_000
51
+ const CACHE_TTL_MS = 60_000
52
+ const SEEN_UPDATE_MS = 60_000
53
+
54
+ const enc = new TextEncoder()
55
+ const b64url = (bytes: ArrayBuffer | Uint8Array): string => {
56
+ const u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
57
+ let s = ""
58
+ for (const b of u) s += String.fromCharCode(b)
59
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
60
+ }
61
+ const hex = (bytes: ArrayBuffer): string => [...new Uint8Array(bytes)].map(b => b.toString(16).padStart(2, "0")).join("")
62
+ export const sha256 = async (text: string): Promise<string> => hex(await crypto.subtle.digest("SHA-256", enc.encode(text)))
63
+ const randomToken = (): string => b64url(crypto.getRandomValues(new Uint8Array(32)))
64
+ const randomCode = (): string => String(crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000).padStart(6, "0")
65
+
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)))
69
+ }
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 }
80
+ }
81
+
82
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
83
+ const normalizeEmail = (email: unknown): string => {
84
+ const e = typeof email === "string" ? email.trim().toLowerCase() : ""
85
+ if (!e || e.length > 254 || !EMAIL_RE.test(e)) throw new ApiError(400, "Invalid email")
86
+ return e
87
+ }
88
+
89
+ /** Built-in user shape from a row (custom scalar fields ride along at runtime). */
90
+ const toUser = (row: any): AuthUser => row as AuthUser
91
+
92
+ export type AuthHost = {
93
+ /** Resolve the bearer token of `ctx` into `ctx.auth` (minting a guest session when needed). No db → leaves it undefined. */
94
+ resolve(ctx: RequestContext): Promise<void>
95
+ ops: AuthOps
96
+ /** Install `ops` for the bundle's `auth` global (`globalThis.__lecodesAuth`). */
97
+ install(): void
98
+ /**
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.
102
+ */
103
+ oauthComplete(provider: Exclude<AuthProvider, "email">, nonce: string, profile: OAuthProfile): Promise<{ userId: number }>
104
+ /** Test hook / diagnostics: forget cached sessions and users. */
105
+ clearCache(): void
106
+ }
107
+
108
+ 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
112
+ const now = cfg.now ?? (() => Date.now())
113
+ const log = cfg.log ?? ((level, text) => (level === "warn" ? console.warn(text) : console.info(text)))
114
+ const providers = new Set(cfg.providers)
115
+
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() }
120
+
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`)
127
+ }
128
+ const stateOf = (ctx: RequestContext): AuthState => {
129
+ const s = ctx.auth as AuthState | undefined
130
+ if (!s) throw new Error("auth: this project has no database — sessions need a defineDb()")
131
+ return s
132
+ }
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
143
+ }
144
+ const mint = async (ctx: RequestContext): Promise<AuthState> => {
145
+ 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() })
149
+ ctx.auth = state
150
+ ctx.sessionToken = token
151
+ return state
152
+ }
153
+ const rotate = async (ctx: RequestContext): Promise<void> => {
154
+ const s = stateOf(ctx)
155
+ const token = randomToken()
156
+ await db.session.update(s.sessionId, { tokenHash: await sha256(token) })
157
+ clearCache()
158
+ s.issuedToken = token
159
+ ctx.sessionToken = token
160
+ }
161
+ const bind = async (ctx: RequestContext, userId: number | null, opts: { rotate: boolean }): Promise<AuthUser | null> => {
162
+ const s = stateOf(ctx)
163
+ await db.session.update(s.sessionId, { user: userId === null ? null : { $connect: { id: userId } } })
164
+ clearCache()
165
+ s.userId = userId
166
+ s.user = await loadUser(userId)
167
+ if (opts.rotate) await rotate(ctx)
168
+ return s.user
169
+ }
170
+
171
+ const resolve = async (ctx: RequestContext): Promise<void> => {
172
+ if (!db) return
173
+ const token = ctx.sessionToken
174
+ if (token) {
175
+ const c = sessionCache.get(token)
176
+ if (c && now() - c.at < CACHE_TTL_MS) {
177
+ ctx.auth = { sessionId: c.sessionId, userId: c.userId, user: await loadUser(c.userId) }
178
+ return
179
+ }
180
+ const row = await db.session.where({ tokenHash: await sha256(token) }).select(sessionSelect()).first()
181
+ 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) }
186
+ return
187
+ }
188
+ }
189
+ await mint(ctx)
190
+ }
191
+
192
+ // ── email codes ──
193
+ const sends = new Map<string, number[]>() // email → send timestamps (per worker; attempts persist on the row)
194
+ const codeHash = (sessionId: number, code: string) => sha256(`${sessionId}:${code}`)
195
+
196
+ 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")
200
+ const s = stateOf(ctx)
201
+ const email = normalizeEmail(rawEmail)
202
+ const t = now()
203
+ const recent = (sends.get(email) ?? []).filter(x => t - x < 10 * 60_000)
204
+ 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
+ if (recent.length >= SEND_MAX_PER_10MIN) throw new ApiError(429, "Too many codes requested — try again later")
206
+ 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)
209
+ const appName = cfg.appName || "the app"
210
+ if (cfg.mail) {
211
+ try { await cfg.mail({ to: email, code, appName }) }
212
+ catch (e) { log("warn", `[auth] could not send the sign-in code to ${email}: ${(e as Error).message}`) }
213
+ }
214
+ 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
+ }
216
+
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!
234
+ }
235
+
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)")
242
+ 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 }
263
+ }
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 }
280
+ }
281
+
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
+ }
296
+
297
+ const ops: AuthOps = {
298
+ email: { sendCode, verify },
299
+ oauth: { url: oauthUrl, signIn: oauthSignIn },
300
+ 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 }))!
305
+ },
306
+ signOut: async (ctx) => { requireDb(); await bind(ctx, null, { rotate: true }) },
307
+ sessions: (ctx) => {
308
+ requireDb()
309
+ const s = stateOf(ctx)
310
+ return s.userId !== null && hasUser ? db.session.where({ user: { id: s.userId } }) : db.session.where({ id: s.sessionId })
311
+ },
312
+ }
313
+
314
+ return {
315
+ resolve, ops, oauthComplete, clearCache,
316
+ install: () => { (globalThis as any).__lecodesAuth = ops },
317
+ }
318
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `auth.model.user({...})` / `auth.model.session()` — the ONLY place auth touches the schema
3
+ * (docs/backend-plan.md §3.4 / decision 10). Both return ordinary `model(...)`s: the developer registers
4
+ * them in `defineDb({ User, Session, … })` under exactly those keys (other models relate to them with
5
+ * `t.one("User")` / `t.one("Session")`), and `defineDb` adds `Session` itself when it isn't declared —
6
+ * a device session exists from the first request, before any login, so every project db has one.
7
+ *
8
+ * Provider identity columns (`googleId`, `appleId`) follow `app.json` `auth.providers`, which the
9
+ * compiler hands the server bundle through ./appConfig.ts (a module it replaces at compile time).
10
+ */
11
+
12
+ import { t, type Field } from "../db/fields"
13
+ import type { Fields, Model } from "../db/types"
14
+ import { app, type AppConfig, type AuthProvider } from "./appConfig"
15
+
16
+ export type { AppConfig, AuthProvider }
17
+
18
+ const seam = globalThis as unknown as { __lecodesApp?: AppConfig }
19
+ /** The project's `app.json` subset: the compiled-in module, else the test/tooling seam, else empty. */
20
+ export const appConfig = (): AppConfig => (Object.keys(app).length ? app : seam.__lecodesApp ?? {})
21
+ export const enabledProviders = (): AuthProvider[] => appConfig().auth?.providers ?? []
22
+
23
+ /** The model name the auth runtime binds to. */
24
+ export const AUTH_USER_MODEL = "User"
25
+ export const AUTH_SESSION_MODEL = "Session"
26
+
27
+ /** Marker on the two auth models (`model()` objects are otherwise plain). */
28
+ export type AuthModelKind = "user" | "session"
29
+ export type AuthModel<F extends Fields> = Model<F> & { readonly __auth: AuthModelKind }
30
+
31
+ // ───────────────────────────── field sets ─────────────────────────────
32
+
33
+ /** Built-in `User` fields (what `auth.user` is typed with). */
34
+ export const userFields = () => ({
35
+ email: t.string().optional().unique(),
36
+ name: t.string().optional(),
37
+ avatar: t.string().optional(),
38
+ /** Plain string column, `"user"` by default — `auth.requireUser({ role: "admin" })` is field equality. */
39
+ role: t.string().default("user"),
40
+ createdAt: t.date().default("now"),
41
+ })
42
+ export type UserFields = ReturnType<typeof userFields>
43
+
44
+ /** `<provider>Id` identity columns for the OAuth providers enabled in app.json. */
45
+ const providerFields = (): Record<string, Field<any, any>> => {
46
+ const out: Record<string, Field<any, any>> = {}
47
+ for (const p of enabledProviders()) if (p !== "email") out[`${p}Id`] = t.string().optional().unique()
48
+ return out
49
+ }
50
+
51
+ /** Built-in `Session` fields. `user` is added by `defineDb` when a `User` model exists (schema-time, so a
52
+ * db without an auth user model has sessions without a user column). */
53
+ export const sessionFields = () => ({
54
+ /** sha256 of the bearer token the device holds — the token itself is never stored. */
55
+ tokenHash: t.string().unique(),
56
+ createdAt: t.date().default("now"),
57
+ lastSeenAt: t.date().default("now"),
58
+ revoked: t.bool().default(false),
59
+ // email-code sign-in state lives on the requesting session (no code table)
60
+ pendingEmail: t.string().optional(),
61
+ codeHash: t.string().optional(),
62
+ codeExpires: t.date().optional(),
63
+ codeAttempts: t.int().default(0),
64
+ })
65
+ export type SessionFields = ReturnType<typeof sessionFields> & { user: ReturnType<typeof sessionUserField> }
66
+ export const sessionUserField = () => t.one(AUTH_USER_MODEL).optional().onDelete("cascade")
67
+
68
+ // ───────────────────────────── factories ─────────────────────────────
69
+
70
+ // Built inline (not via defineDb's `model()`) to keep this module import-free of defineDb, which imports us.
71
+ const authModel = <F extends Fields>(kind: AuthModelKind, fields: F): AuthModel<F> =>
72
+ ({ fields, __model: true, __auth: kind }) as AuthModel<F>
73
+
74
+ /** `auth.model.user({...extra})` — the built-in user (+ provider ids from app.json) plus the developer's fields. */
75
+ export const authUserModel = <E extends Fields = {}>(extra?: E): AuthModel<UserFields & E> =>
76
+ authModel("user", { ...userFields(), ...providerFields(), ...(extra ?? {}) } as UserFields & E)
77
+
78
+ /** `auth.model.session({...extra})` — declare it only to relate to sessions (`t.one("Session")`) or to add fields. */
79
+ export const authSessionModel = <E extends Fields = {}>(extra?: E): AuthModel<SessionFields & E> =>
80
+ authModel("session", { ...sessionFields(), ...(extra ?? {}) } as unknown as SessionFields & E)
81
+
82
+ export const isAuthModel = (m: unknown, kind?: AuthModelKind): m is AuthModel<any> =>
83
+ typeof m === "object" && m !== null && "__auth" in m && (kind === undefined || (m as AuthModel<any>).__auth === kind)
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shared shapes of the auth layer (docs/backend-plan.md §3.4). Two sides meet through them:
3
+ * - the BUNDLE side (`auth` global, ./global.ts) — what `*.server.ts` code calls;
4
+ * - the HOST side (./host.ts) — sessions, email codes and OAuth binding, run by the runner with the
5
+ * project's db handle and the platform's secrets. Installed on `globalThis.__lecodesAuth`.
6
+ */
7
+
8
+ import type { RequestContext } from "../context"
9
+ import type { AuthProvider } from "./models"
10
+
11
+ /** The built-in user row — what `auth.user` is typed with (custom fields exist at runtime; select them via `db.user`). */
12
+ export type AuthUser = {
13
+ id: number
14
+ email: string | null
15
+ name: string | null
16
+ avatar: string | null
17
+ role: string
18
+ createdAt: number
19
+ }
20
+
21
+ /** Per-request auth state, resolved by the host before the endpoint runs (`RequestContext.auth`). */
22
+ export type AuthState = {
23
+ sessionId: number
24
+ userId: number | null
25
+ user: AuthUser | null
26
+ /** A token to hand back to the client in this response (a new guest session, or a rotation). */
27
+ issuedToken?: string
28
+ }
29
+
30
+ /** What an OAuth provider hands the runner after a successful web sign-in. */
31
+ export type OAuthProfile = { sub: string, email?: string | null, emailVerified?: boolean, name?: string | null, avatar?: string | null }
32
+ /** A credential a NATIVE provider SDK produced in the app (client plugin `OAuth.signIn`): an access token
33
+ * (Yandex, VK ID) or an OpenID identity token (Google, Apple). Verified server-side, never trusted as-is. */
34
+ export type OAuthCredential = { accessToken?: string, idToken?: string, name?: string | null, email?: string | null }
35
+
36
+ /** Host operations the bundle's `auth` global forwards to (all request-scoped through `ctx`). */
37
+ export type AuthOps = {
38
+ email: {
39
+ sendCode(ctx: RequestContext, email: string): Promise<void>
40
+ verify(ctx: RequestContext, email: string, code: string): Promise<AuthUser>
41
+ }
42
+ oauth: {
43
+ url(ctx: RequestContext, provider: Exclude<AuthProvider, "email">): Promise<string>
44
+ signIn(ctx: RequestContext, provider: Exclude<AuthProvider, "email">, credential: OAuthCredential): Promise<AuthUser>
45
+ }
46
+ signIn(ctx: RequestContext, userId: number): Promise<AuthUser>
47
+ signOut(ctx: RequestContext): Promise<void>
48
+ /** `db.session` filtered to the current user's sessions (or just this device's, when signed out). */
49
+ sessions(ctx: RequestContext): any
50
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Server side of channels (docs/backend-plan.md §3.3): typed pub/sub, server → client only.
3
+ *
4
+ * type ChatEvents = { message: { from: string; text: string }; typing: { from: string } }
5
+ * export const chat = channel<ChatEvents>({ onJoin: room => { auth.requireUser() } })
6
+ * chat.publish(room, "message", { from, text })
7
+ *
8
+ * A channel's identity is its export (`<path>#<name>`), assigned by the runtime when the bundle is
9
+ * loaded (`loadServerModules`); `publish` before that is an error. Delivery goes through the
10
+ * `globalThis.__lecodesPublish` seam the runner installs (same reason as ./context.ts: the bundle
11
+ * carries its own SDK copy).
12
+ */
13
+
14
+ export type ChannelEvents = Record<string, unknown>
15
+
16
+ export type ChannelOptions = {
17
+ /** Called when a client subscribes to `topic`; throw (e.g. `ApiError(403)`) to refuse. */
18
+ onJoin?: (topic: string) => void | Promise<void>
19
+ }
20
+
21
+ export interface Channel<E extends ChannelEvents> {
22
+ /** @internal set by the runtime: `<path>#<export>` */
23
+ __id: string | null
24
+ /** @internal */
25
+ readonly __channel: true
26
+ readonly options: ChannelOptions
27
+ /** Deliver `event` with `payload` to every subscriber of `topic` (at-most-once). */
28
+ publish<K extends keyof E & string>(topic: string, event: K, payload: E[K]): void
29
+ }
30
+
31
+ type Publisher = (channelId: string, topic: string, event: string, payload: unknown) => void
32
+ const seam = globalThis as unknown as { __lecodesPublish?: Publisher }
33
+
34
+ /** Host hook (runner / test harness): where `publish` delivers to. */
35
+ export const setChannelPublisher = (publisher: Publisher | null) => {
36
+ if (publisher) seam.__lecodesPublish = publisher
37
+ else delete seam.__lecodesPublish
38
+ }
39
+
40
+ export const channel = <E extends ChannelEvents>(options: ChannelOptions = {}): Channel<E> => {
41
+ const ch: Channel<E> = {
42
+ __id: null,
43
+ __channel: true,
44
+ options,
45
+ publish(topic, event, payload) {
46
+ if (!ch.__id) throw new Error("channel.publish: the channel is not registered — export it from a *.server.ts file")
47
+ const publish = seam.__lecodesPublish
48
+ if (!publish) throw new Error("channel.publish: no host publisher installed")
49
+ publish(ch.__id, topic, event, payload)
50
+ },
51
+ }
52
+ return ch
53
+ }
54
+
55
+ export const isChannel = (v: unknown): v is Channel<any> =>
56
+ typeof v === "object" && v !== null && (v as Channel<any>).__channel === true
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Request context for server code. The runner keeps the current request in an AsyncLocalStorage
3
+ * and exposes it through `globalThis.__lecodesRequest` — a *global* seam on purpose: the compiled
4
+ * server bundle carries its own copy of this module (chisel bundles the SDK in), so the host can't
5
+ * hand it a value by import. `request` reads through that seam; outside a request (module load,
6
+ * timers after the response) every member is undefined.
7
+ */
8
+
9
+ export type RequestContext = {
10
+ /** Endpoint id `<path>#<export>` (or the channel id for `onJoin`). */
11
+ id: string
12
+ headers: Record<string, string>
13
+ ip?: string
14
+ /** Bearer session token as sent by the client transport (raw; auth resolves it). */
15
+ sessionToken?: string
16
+ /** Set by the auth runtime after resolving the token — read via `auth.*`, not here. */
17
+ auth?: unknown
18
+ }
19
+
20
+ type Provider = () => RequestContext | undefined
21
+ const seam = globalThis as unknown as { __lecodesRequest?: Provider }
22
+
23
+ /** Host hook (runner / test harness): install the current-request provider. */
24
+ export const setRequestProvider = (provider: Provider | null) => {
25
+ if (provider) seam.__lecodesRequest = provider
26
+ else delete seam.__lecodesRequest
27
+ }
28
+ /** @internal */
29
+ export const currentRequest = (): RequestContext | undefined => seam.__lecodesRequest?.()
30
+
31
+ /** The current request (server global). Undefined members outside a request. */
32
+ export const request = {
33
+ get id(): string | undefined { return currentRequest()?.id },
34
+ get headers(): Record<string, string> { return currentRequest()?.headers ?? {} },
35
+ get ip(): string | undefined { return currentRequest()?.ip },
36
+ }