lecodes-sdk 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (367) hide show
  1. package/README.md +114 -0
  2. package/dist/global.d.ts +503 -0
  3. package/dist/host.d.ts +61 -0
  4. package/dist/inject.js +4730 -0
  5. package/dist/types/animate/animate.d.ts +20 -0
  6. package/dist/types/animate/bezier.d.ts +2 -0
  7. package/dist/types/animate/easings.d.ts +21 -0
  8. package/dist/types/canvas/Canvas.d.ts +168 -0
  9. package/dist/types/core/Aspect.d.ts +171 -0
  10. package/dist/types/core/InspectorUI.d.ts +88 -0
  11. package/dist/types/core/StateMachine.d.ts +81 -0
  12. package/dist/types/core/color.d.ts +13 -0
  13. package/dist/types/core/compWrite.d.ts +11 -0
  14. package/dist/types/core/events.d.ts +11 -0
  15. package/dist/types/core/fields.d.ts +58 -0
  16. package/dist/types/core/registry.d.ts +7 -0
  17. package/dist/types/core/signals.d.ts +29 -0
  18. package/dist/types/core/time.d.ts +31 -0
  19. package/dist/types/g2/Camera2D.d.ts +19 -0
  20. package/dist/types/g2/CharacterController2D.d.ts +134 -0
  21. package/dist/types/g2/Node2D.d.ts +91 -0
  22. package/dist/types/g2/OneWay2D.d.ts +24 -0
  23. package/dist/types/g2/Physics2D.d.ts +147 -0
  24. package/dist/types/g2/Scene2D.d.ts +72 -0
  25. package/dist/types/g2/Shape2D.d.ts +58 -0
  26. package/dist/types/g2/Sprite.d.ts +51 -0
  27. package/dist/types/g2/SpriteAnimation.d.ts +50 -0
  28. package/dist/types/g2/SpriteSheet.d.ts +69 -0
  29. package/dist/types/g2/Texture2D.d.ts +16 -0
  30. package/dist/types/g2/Tilemap.d.ts +20 -0
  31. package/dist/types/g2/Tileset.d.ts +32 -0
  32. package/dist/types/g2/Trigger2D.d.ts +20 -0
  33. package/dist/types/g2/autotile.d.ts +133 -0
  34. package/dist/types/g2/cells.d.ts +14 -0
  35. package/dist/types/g2/defineScene2d.d.ts +141 -0
  36. package/dist/types/g2/groups2d.d.ts +16 -0
  37. package/dist/types/g2/loop.d.ts +4 -0
  38. package/dist/types/g2/scenarios2d.d.ts +30 -0
  39. package/dist/types/g2/touch.d.ts +2 -0
  40. package/dist/types/gl/Camera.d.ts +79 -0
  41. package/dist/types/gl/CameraPlace.d.ts +26 -0
  42. package/dist/types/gl/CharacterController.d.ts +98 -0
  43. package/dist/types/gl/Gearbox.d.ts +86 -0
  44. package/dist/types/gl/Geometry.d.ts +39 -0
  45. package/dist/types/gl/IK.d.ts +53 -0
  46. package/dist/types/gl/InstancedMesh.d.ts +46 -0
  47. package/dist/types/gl/Light.d.ts +73 -0
  48. package/dist/types/gl/Lightmap.d.ts +85 -0
  49. package/dist/types/gl/Locomotion.d.ts +279 -0
  50. package/dist/types/gl/Material.d.ts +100 -0
  51. package/dist/types/gl/Mesh.d.ts +33 -0
  52. package/dist/types/gl/Model.d.ts +53 -0
  53. package/dist/types/gl/NavAgent.d.ts +99 -0
  54. package/dist/types/gl/NavMesh.d.ts +169 -0
  55. package/dist/types/gl/Node.d.ts +99 -0
  56. package/dist/types/gl/Noise.d.ts +20 -0
  57. package/dist/types/gl/Particles.d.ts +277 -0
  58. package/dist/types/gl/Physics.d.ts +91 -0
  59. package/dist/types/gl/Plane.d.ts +19 -0
  60. package/dist/types/gl/Ragdoll.d.ts +86 -0
  61. package/dist/types/gl/Ray.d.ts +8 -0
  62. package/dist/types/gl/Scene.d.ts +221 -0
  63. package/dist/types/gl/Shape.d.ts +122 -0
  64. package/dist/types/gl/Terrain.d.ts +280 -0
  65. package/dist/types/gl/Texture.d.ts +27 -0
  66. package/dist/types/gl/Trigger.d.ts +10 -0
  67. package/dist/types/gl/Vehicle.d.ts +191 -0
  68. package/dist/types/gl/Wheel.d.ts +95 -0
  69. package/dist/types/gl/animation/AnimationClip.d.ts +60 -0
  70. package/dist/types/gl/animation/Animator.d.ts +219 -0
  71. package/dist/types/gl/animation/Layer.d.ts +31 -0
  72. package/dist/types/gl/animation/Loop.d.ts +17 -0
  73. package/dist/types/gl/animation/Playback.d.ts +36 -0
  74. package/dist/types/gl/animation/core.d.ts +367 -0
  75. package/dist/types/gl/controls.d.ts +19 -0
  76. package/dist/types/gl/physicsEvents.d.ts +1 -0
  77. package/dist/types/gl/scenarios.d.ts +98 -0
  78. package/dist/types/gl/state.d.ts +5 -0
  79. package/dist/types/gl/terrainMesh.d.ts +46 -0
  80. package/dist/types/gl/touch.d.ts +2 -0
  81. package/dist/types/inject.d.ts +123 -0
  82. package/dist/types/math/Mathf.d.ts +39 -0
  83. package/dist/types/math/mat4.d.ts +66 -0
  84. package/dist/types/math/quat.d.ts +53 -0
  85. package/dist/types/math/vec.d.ts +129 -0
  86. package/dist/types/net/codec.d.ts +25 -0
  87. package/dist/types/net/core.d.ts +106 -0
  88. package/dist/types/net/index.d.ts +80 -0
  89. package/dist/types/net/replication.d.ts +101 -0
  90. package/dist/types/plugins/camera.d.ts +25 -0
  91. package/dist/types/plugins/geolocation.d.ts +39 -0
  92. package/dist/types/plugins/oauth.d.ts +25 -0
  93. package/dist/types/plugins/permission.d.ts +1 -0
  94. package/dist/types/plugins/push.d.ts +46 -0
  95. package/dist/types/plugins/qr.d.ts +21 -0
  96. package/dist/types/plugins/service.d.ts +33 -0
  97. package/dist/types/runtime/app.d.ts +67 -0
  98. package/dist/types/runtime/appEvents.d.ts +7 -0
  99. package/dist/types/runtime/channel.d.ts +1 -0
  100. package/dist/types/runtime/clipboard.d.ts +7 -0
  101. package/dist/types/runtime/datetime.d.ts +80 -0
  102. package/dist/types/runtime/device.d.ts +126 -0
  103. package/dist/types/runtime/fetch.d.ts +31 -0
  104. package/dist/types/runtime/files.d.ts +33 -0
  105. package/dist/types/runtime/input.d.ts +121 -0
  106. package/dist/types/runtime/media.d.ts +46 -0
  107. package/dist/types/runtime/misc.d.ts +11 -0
  108. package/dist/types/runtime/net.d.ts +15 -0
  109. package/dist/types/runtime/rpc.d.ts +44 -0
  110. package/dist/types/runtime/service.d.ts +1 -0
  111. package/dist/types/runtime/share.d.ts +2 -0
  112. package/dist/types/runtime/storage.d.ts +5 -0
  113. package/dist/types/runtime/touch.d.ts +56 -0
  114. package/dist/types/scene/defineScene.d.ts +296 -0
  115. package/dist/types/scene/editorPlugins.d.ts +82 -0
  116. package/dist/types/scene/gizmos.d.ts +56 -0
  117. package/dist/types/scene/grammar.d.ts +56 -0
  118. package/dist/types/scene/level.d.ts +98 -0
  119. package/dist/types/scene/material.d.ts +76 -0
  120. package/dist/types/server/auth/appConfig.d.ts +16 -0
  121. package/dist/types/server/auth/global.d.ts +56 -0
  122. package/dist/types/server/auth/models.d.ts +164 -0
  123. package/dist/types/server/auth/types.d.ts +56 -0
  124. package/dist/types/server/channel.d.ts +28 -0
  125. package/dist/types/server/context.d.ts +27 -0
  126. package/dist/types/server/db/defineDb.d.ts +36 -0
  127. package/dist/types/server/db/fields.d.ts +146 -0
  128. package/dist/types/server/db/index.d.ts +6 -0
  129. package/dist/types/server/db/marci/query.d.ts +326 -0
  130. package/dist/types/server/db/types.d.ts +163 -0
  131. package/dist/types/server/errors.d.ts +8 -0
  132. package/dist/types/server/inject.d.ts +5 -0
  133. package/dist/types/ui/NativeView.d.ts +38 -0
  134. package/dist/types/ui/UI.d.ts +27 -0
  135. package/dist/types/ui/UIBottomSheet.d.ts +71 -0
  136. package/dist/types/ui/UIButton.d.ts +30 -0
  137. package/dist/types/ui/UIContainer.d.ts +33 -0
  138. package/dist/types/ui/UIImage.d.ts +35 -0
  139. package/dist/types/ui/UIInput.d.ts +110 -0
  140. package/dist/types/ui/UIModal.d.ts +70 -0
  141. package/dist/types/ui/UINode.d.ts +363 -0
  142. package/dist/types/ui/UIPager.d.ts +123 -0
  143. package/dist/types/ui/UIPopover.d.ts +39 -0
  144. package/dist/types/ui/UIScreen.d.ts +57 -0
  145. package/dist/types/ui/UIScrollable.d.ts +40 -0
  146. package/dist/types/ui/UISpacer.d.ts +9 -0
  147. package/dist/types/ui/UITabs.d.ts +96 -0
  148. package/dist/types/ui/UIText.d.ts +19 -0
  149. package/dist/types/ui/UIVideo.d.ts +28 -0
  150. package/dist/types/ui/UIVirtualizedList.d.ts +97 -0
  151. package/dist/types/ui/UIWidget.d.ts +65 -0
  152. package/dist/types/ui/fonts.d.ts +9 -0
  153. package/dist/types/ui/presentable.d.ts +68 -0
  154. package/dist/types/ui/router.d.ts +40 -0
  155. package/dist/types/ui/theme.d.ts +26 -0
  156. package/dist/types.json +1 -0
  157. package/package.json +46 -0
  158. package/prompts/2d.md +395 -0
  159. package/prompts/3d-scene-files.md +113 -0
  160. package/prompts/3d-scene.md +119 -0
  161. package/prompts/3d.md +303 -0
  162. package/prompts/README.md +142 -0
  163. package/prompts/ar.md +109 -0
  164. package/prompts/canvas.md +27 -0
  165. package/prompts/compose.ts +46 -0
  166. package/prompts/concept.md +49 -0
  167. package/prompts/core-design.md +75 -0
  168. package/prompts/core.md +264 -0
  169. package/prompts/design.md +218 -0
  170. package/prompts/dist/2d-game.md +1530 -0
  171. package/prompts/dist/3d-app.md +1672 -0
  172. package/prompts/dist/ar-app.md +1548 -0
  173. package/prompts/dist/concept.md +49 -0
  174. package/prompts/dist/design.md +498 -0
  175. package/prompts/dist/ui-app.md +1134 -0
  176. package/prompts/index.md +37 -0
  177. package/prompts/intake-prompt.md +33 -0
  178. package/prompts/namer-prompt.md +12 -0
  179. package/prompts/router-prompt.md +50 -0
  180. package/prompts/select.ts +111 -0
  181. package/prompts/ui-design.md +203 -0
  182. package/prompts/ui.md +803 -0
  183. package/src/animate/animate.ts +238 -0
  184. package/src/animate/bezier.ts +138 -0
  185. package/src/animate/easings.ts +126 -0
  186. package/src/bridges.d.ts +1345 -0
  187. package/src/canvas/Canvas.ts +305 -0
  188. package/src/compile/__tests__/assetIconMacro.test.ts +219 -0
  189. package/src/compile/__tests__/assetMacro.test.ts +74 -0
  190. package/src/compile/__tests__/assetName.test.ts +55 -0
  191. package/src/compile/__tests__/compile.test.ts +287 -0
  192. package/src/compile/__tests__/detectEntry.test.ts +132 -0
  193. package/src/compile/__tests__/fontMacro.test.ts +199 -0
  194. package/src/compile/aspectMacro.ts +86 -0
  195. package/src/compile/assetIconMacro.ts +384 -0
  196. package/src/compile/assetMacro.ts +146 -0
  197. package/src/compile/assetName.ts +50 -0
  198. package/src/compile/bundler.ts +287 -0
  199. package/src/compile/compileProject.ts +127 -0
  200. package/src/compile/detectEntry.ts +128 -0
  201. package/src/compile/fontMacro.ts +459 -0
  202. package/src/compile/fontRegistry.ts +78 -0
  203. package/src/compile/header.ts +67 -0
  204. package/src/compile/index.ts +100 -0
  205. package/src/compile/libraryImports.ts +52 -0
  206. package/src/compile/liteMaterial.ts +247 -0
  207. package/src/compile/sceneEditor.ts +88 -0
  208. package/src/compile/serverSplit.ts +233 -0
  209. package/src/compile/serverTypes.ts +227 -0
  210. package/src/compile/sfnt.ts +98 -0
  211. package/src/compile/shaderSchema.ts +202 -0
  212. package/src/compile/shaderTargets.ts +77 -0
  213. package/src/compile/sourcemap.ts +25 -0
  214. package/src/core/Aspect.ts +568 -0
  215. package/src/core/InspectorUI.ts +212 -0
  216. package/src/core/StateMachine.ts +308 -0
  217. package/src/core/__tests__/stateMachine.test.ts +132 -0
  218. package/src/core/color.ts +66 -0
  219. package/src/core/compWrite.ts +42 -0
  220. package/src/core/events.ts +38 -0
  221. package/src/core/fields.ts +120 -0
  222. package/src/core/registry.ts +23 -0
  223. package/src/core/signals.ts +277 -0
  224. package/src/core/time.ts +84 -0
  225. package/src/g2/Camera2D.ts +40 -0
  226. package/src/g2/CharacterController2D.ts +276 -0
  227. package/src/g2/Node2D.ts +267 -0
  228. package/src/g2/OneWay2D.ts +66 -0
  229. package/src/g2/Physics2D.ts +346 -0
  230. package/src/g2/Scene2D.ts +209 -0
  231. package/src/g2/Shape2D.ts +259 -0
  232. package/src/g2/Sprite.ts +89 -0
  233. package/src/g2/SpriteAnimation.ts +171 -0
  234. package/src/g2/SpriteSheet.ts +166 -0
  235. package/src/g2/Texture2D.ts +47 -0
  236. package/src/g2/Tilemap.ts +41 -0
  237. package/src/g2/Tileset.ts +71 -0
  238. package/src/g2/Trigger2D.ts +77 -0
  239. package/src/g2/autotile.ts +433 -0
  240. package/src/g2/cells.ts +91 -0
  241. package/src/g2/defineScene2d.ts +381 -0
  242. package/src/g2/groups2d.ts +106 -0
  243. package/src/g2/loop.ts +50 -0
  244. package/src/g2/scenarios2d.ts +69 -0
  245. package/src/g2/touch.ts +83 -0
  246. package/src/gl/Camera.ts +160 -0
  247. package/src/gl/CameraPlace.ts +52 -0
  248. package/src/gl/CharacterController.ts +238 -0
  249. package/src/gl/Gearbox.ts +212 -0
  250. package/src/gl/Geometry.ts +279 -0
  251. package/src/gl/IK.ts +193 -0
  252. package/src/gl/InstancedMesh.ts +132 -0
  253. package/src/gl/Light.ts +135 -0
  254. package/src/gl/Lightmap.ts +402 -0
  255. package/src/gl/Locomotion.ts +498 -0
  256. package/src/gl/Material.ts +279 -0
  257. package/src/gl/Mesh.ts +83 -0
  258. package/src/gl/Model.ts +124 -0
  259. package/src/gl/NavAgent.ts +337 -0
  260. package/src/gl/NavMesh.ts +397 -0
  261. package/src/gl/Node.ts +350 -0
  262. package/src/gl/Noise.ts +30 -0
  263. package/src/gl/Particles.ts +676 -0
  264. package/src/gl/Physics.ts +222 -0
  265. package/src/gl/Plane.ts +53 -0
  266. package/src/gl/Ragdoll.ts +270 -0
  267. package/src/gl/Ray.ts +16 -0
  268. package/src/gl/Scene.ts +505 -0
  269. package/src/gl/Shape.ts +409 -0
  270. package/src/gl/Terrain.ts +1092 -0
  271. package/src/gl/Texture.ts +63 -0
  272. package/src/gl/Trigger.ts +45 -0
  273. package/src/gl/Vehicle.ts +473 -0
  274. package/src/gl/Wheel.ts +240 -0
  275. package/src/gl/animation/AnimationClip.ts +204 -0
  276. package/src/gl/animation/Animator.ts +329 -0
  277. package/src/gl/animation/Layer.ts +39 -0
  278. package/src/gl/animation/Loop.ts +31 -0
  279. package/src/gl/animation/Playback.ts +52 -0
  280. package/src/gl/animation/core.ts +670 -0
  281. package/src/gl/controls.ts +95 -0
  282. package/src/gl/physicsEvents.ts +20 -0
  283. package/src/gl/scenarios.ts +291 -0
  284. package/src/gl/state.ts +6 -0
  285. package/src/gl/terrainMesh.ts +219 -0
  286. package/src/gl/touch.ts +68 -0
  287. package/src/host.d.ts +61 -0
  288. package/src/inject.ts +203 -0
  289. package/src/math/Mathf.ts +118 -0
  290. package/src/math/mat4.ts +278 -0
  291. package/src/math/quat.ts +232 -0
  292. package/src/math/vec.ts +255 -0
  293. package/src/net/codec.ts +119 -0
  294. package/src/net/core.ts +384 -0
  295. package/src/net/index.ts +181 -0
  296. package/src/net/replication.ts +622 -0
  297. package/src/plugins/camera.ts +81 -0
  298. package/src/plugins/geolocation.ts +123 -0
  299. package/src/plugins/oauth.ts +61 -0
  300. package/src/plugins/permission.ts +7 -0
  301. package/src/plugins/push.ts +132 -0
  302. package/src/plugins/qr.ts +73 -0
  303. package/src/plugins/service.ts +47 -0
  304. package/src/runtime/app.ts +101 -0
  305. package/src/runtime/appEvents.ts +54 -0
  306. package/src/runtime/channel.ts +61 -0
  307. package/src/runtime/clipboard.ts +20 -0
  308. package/src/runtime/datetime.ts +329 -0
  309. package/src/runtime/device.ts +293 -0
  310. package/src/runtime/fetch.ts +77 -0
  311. package/src/runtime/files.ts +108 -0
  312. package/src/runtime/input.ts +175 -0
  313. package/src/runtime/media.ts +111 -0
  314. package/src/runtime/misc.ts +16 -0
  315. package/src/runtime/net.ts +36 -0
  316. package/src/runtime/rpc.ts +218 -0
  317. package/src/runtime/service.ts +83 -0
  318. package/src/runtime/share.ts +9 -0
  319. package/src/runtime/storage.ts +13 -0
  320. package/src/runtime/touch.ts +76 -0
  321. package/src/scene/defineScene.ts +1396 -0
  322. package/src/scene/editorPlugins.ts +110 -0
  323. package/src/scene/gizmos.ts +148 -0
  324. package/src/scene/grammar.ts +120 -0
  325. package/src/scene/level.ts +296 -0
  326. package/src/scene/material.ts +188 -0
  327. package/src/server/auth/appConfig.ts +12 -0
  328. package/src/server/auth/global.ts +80 -0
  329. package/src/server/auth/host.ts +318 -0
  330. package/src/server/auth/models.ts +83 -0
  331. package/src/server/auth/types.ts +50 -0
  332. package/src/server/channel.ts +56 -0
  333. package/src/server/context.ts +36 -0
  334. package/src/server/db/defineDb.ts +237 -0
  335. package/src/server/db/fields.ts +132 -0
  336. package/src/server/db/httpTransport.ts +93 -0
  337. package/src/server/db/index.ts +7 -0
  338. package/src/server/db/marci/query.ts +412 -0
  339. package/src/server/db/types.ts +202 -0
  340. package/src/server/errors.ts +12 -0
  341. package/src/server/host.ts +74 -0
  342. package/src/server/inject.ts +13 -0
  343. package/src/server/runtime.ts +133 -0
  344. package/src/server/validate.ts +87 -0
  345. package/src/ui/NativeView.ts +142 -0
  346. package/src/ui/UI.ts +39 -0
  347. package/src/ui/UIBottomSheet.ts +139 -0
  348. package/src/ui/UIButton.ts +101 -0
  349. package/src/ui/UIContainer.ts +60 -0
  350. package/src/ui/UIImage.ts +83 -0
  351. package/src/ui/UIInput.ts +185 -0
  352. package/src/ui/UIModal.ts +139 -0
  353. package/src/ui/UINode.ts +830 -0
  354. package/src/ui/UIPager.ts +362 -0
  355. package/src/ui/UIPopover.ts +100 -0
  356. package/src/ui/UIScreen.ts +123 -0
  357. package/src/ui/UIScrollable.ts +87 -0
  358. package/src/ui/UISpacer.ts +14 -0
  359. package/src/ui/UITabs.ts +236 -0
  360. package/src/ui/UIText.ts +51 -0
  361. package/src/ui/UIVideo.ts +88 -0
  362. package/src/ui/UIVirtualizedList.ts +241 -0
  363. package/src/ui/UIWidget.ts +127 -0
  364. package/src/ui/fonts.ts +13 -0
  365. package/src/ui/presentable.ts +117 -0
  366. package/src/ui/router.ts +132 -0
  367. package/src/ui/theme.ts +84 -0
@@ -0,0 +1,188 @@
1
+ // Materials as data — the `material:` grammar of scene files and the `defineMaterial` document
2
+ // behind `*.material.ts` assets (docs/scene-materials-plan.md).
3
+ //
4
+ // // materials/holo-glass.material.ts
5
+ // export default defineMaterial({
6
+ // shader: asset('../assets/shaders/lens.mat'),
7
+ // params: { tint: '#1d2b26', opacity: 0.22, mask: asset('../assets/weapons/reticle.png') },
8
+ // })
9
+ //
10
+ // // range.scene.ts
11
+ // import holoGlass from './materials/holo-glass.material'
12
+ // lens: { mesh: { kind: 'plane' }, material: holoGlass },
13
+ //
14
+ // One grammar serves the inline form (`material: { lit: {…} }` / `{ shader, params }` on a node),
15
+ // the asset form (the handle) and GLB-part overrides. A handle is ONE shared GPU material: every
16
+ // node using the asset renders the same instance, and editing the asset changes all of them
17
+ // (Unity's rule — two looks are two assets).
18
+ //
19
+ // Parameter values need no shader metadata at runtime: a string is a colour when it starts with
20
+ // `#`, otherwise it is a texture URL (`asset('./x.png')` compiles to one) — the same lowering
21
+ // `Material.set` already applies to colours.
22
+
23
+ import type { ColorInput } from "../core/color"
24
+ import { Material, type LitMaterialOptions, type UnlitMaterialOptions } from "../gl/Material"
25
+ import { Texture } from "../gl/Texture"
26
+ import type { Node } from "../gl/Node"
27
+ import { EDIT_FLAG } from "./grammar"
28
+
29
+ /** A shader parameter value as scene files write it: number, boolean, `#colour`, texture URL
30
+ * (`asset('./x.png')`), or a numeric vector. */
31
+ export type MaterialParamValue = number | boolean | string | number[]
32
+
33
+ type WithMapUrl<T> = Omit<T, "map"> & {
34
+ /** Base-colour texture — a `Texture`, a `Canvas`, or `asset('./x.png')` (a URL string). */
35
+ map?: LitMaterialOptions["map"] | string
36
+ }
37
+
38
+ export type LitMaterialDef = WithMapUrl<LitMaterialOptions>
39
+ export type UnlitMaterialDef = WithMapUrl<UnlitMaterialOptions>
40
+
41
+ /** A custom Filament shader (`asset('./x.mat')` — compiled by the platform / `lecodes shaders`)
42
+ * plus its parameter values. */
43
+ export type ShaderMaterialDef = {
44
+ shader: string
45
+ params?: Record<string, MaterialParamValue>
46
+ }
47
+
48
+ export type MaterialDef =
49
+ | { lit: LitMaterialDef }
50
+ | { unlit: UnlitMaterialDef }
51
+ | { shadow: ColorInput }
52
+ | ShaderMaterialDef
53
+ /** A material asset (`import m from './x.material'`) — one shared instance. */
54
+ | MaterialHandle
55
+ /** A code-level Material instance — valid at runtime; the editor shows it read-only. */
56
+ | Material
57
+
58
+ /** The data forms of MaterialDef (everything but a live instance / handle). */
59
+ export type MaterialData = Exclude<MaterialDef, MaterialHandle | Material>
60
+
61
+ const isTextureUrl = (v: unknown): v is string => typeof v === "string" && !v.startsWith("#")
62
+
63
+ /** Push one parameter into a live material. Textures load asynchronously — the returned promise
64
+ * settles once the value is applied (immediately for scalars). */
65
+ export const applyMaterialParam = (material: Material, name: string, value: MaterialParamValue | null): Promise<void> => {
66
+ if (isTextureUrl(value)) {
67
+ return Texture.load(value).then((tex) => { material.set(name, tex) }, (e) => {
68
+ console.warn(`[material] "${name}": ${e instanceof Error ? e.message : String(e)}`)
69
+ })
70
+ }
71
+ material.set(name, value as never)
72
+ return Promise.resolve()
73
+ }
74
+
75
+ /** Build a live Material from a data def. Resolves once every texture it names has loaded. */
76
+ export const createMaterialFromData = async (def: MaterialData): Promise<Material> => {
77
+ if ("shader" in def) {
78
+ const m = await Material.load(def.shader)
79
+ await Promise.all(Object.entries(def.params ?? {}).map(([ k, v ]) => applyMaterialParam(m, k, v)))
80
+ return m
81
+ }
82
+ if ("shadow" in def) return Material.shadow(def.shadow)
83
+ const lit = "lit" in def
84
+ const opts = lit ? def.lit : def.unlit
85
+ const { map, ...rest } = opts
86
+ const m = lit ? Material.lit(rest as LitMaterialOptions) : Material.unlit(rest as UnlitMaterialOptions)
87
+ if (isTextureUrl(map)) m.map = await Texture.load(map)
88
+ else if (map !== undefined) m.map = map as LitMaterialOptions["map"] as Texture | null
89
+ return m
90
+ }
91
+
92
+ /**
93
+ * A material asset: the default export of a `*.material.ts` file. `load()` yields ONE shared
94
+ * `Material` (cached); scene files hand the handle to `material:` and the loader resolves it.
95
+ * The editor edits the asset live through `setParam` / `reset` — every node the handle was
96
+ * applied to follows (`_users`).
97
+ */
98
+ export class MaterialHandle {
99
+ def: MaterialData
100
+ private _instance: Material | null = null
101
+ private _loading: Promise<Material> | null = null
102
+ /** Every (node, slot) this handle was applied to through a scene — the editor re-assigns them
103
+ * when the asset's shader changes. */
104
+ readonly _users = new Set<{ node: Node, slot: number }>()
105
+
106
+ constructor(def: MaterialData) {
107
+ this.def = def
108
+ }
109
+
110
+ /** The shared material instance (built on first use). */
111
+ load(): Promise<Material> {
112
+ if (this._instance) return Promise.resolve(this._instance)
113
+ this._loading ??= createMaterialFromData(this.def).then((m) => { this._instance = m; return m })
114
+ return this._loading
115
+ }
116
+
117
+ /** The live instance, if built yet. */
118
+ get material(): Material | null { return this._instance }
119
+
120
+ /** Editor: assign this material to `node`'s `slot`, remembering the user. */
121
+ async _applyTo(node: Node, slot: number): Promise<Material> {
122
+ const m = await this.load()
123
+ node.setMaterial(m, slot)
124
+ this._users.add({ node, slot })
125
+ return m
126
+ }
127
+
128
+ /** Editor: one parameter value, applied live to the shared instance (a texture loads in). */
129
+ setParam(name: string, value: MaterialParamValue | null): void {
130
+ const def = this.def
131
+ if ("shader" in def) {
132
+ def.params ??= {}
133
+ if (value === null) delete def.params[name]
134
+ else def.params[name] = value
135
+ } else if ("lit" in def || "unlit" in def) {
136
+ const opts = ("lit" in def ? def.lit : def.unlit) as Record<string, unknown>
137
+ if (value === null) delete opts[name]
138
+ else opts[name] = value
139
+ }
140
+ const m = this._instance
141
+ if (!m) return
142
+ if (!("shader" in def) && name === "color") { m.color = value as string; return }
143
+ if (!("shader" in def) && name === "map") {
144
+ if (isTextureUrl(value)) void Texture.load(value).then((t) => { m.map = t })
145
+ else m.map = null
146
+ return
147
+ }
148
+ void applyMaterialParam(m, name, value)
149
+ }
150
+
151
+ /** Editor: replace the whole definition (a shader/kind switch) — rebuilds the instance and
152
+ * re-assigns every user. */
153
+ async reset(def: MaterialData): Promise<Material> {
154
+ this.def = def
155
+ this._instance = null
156
+ this._loading = null
157
+ const m = await this.load()
158
+ for (const u of this._users) u.node.setMaterial(m, u.slot)
159
+ return m
160
+ }
161
+ }
162
+
163
+ /** Resolve any `material:` value to a live Material (a handle's shared instance, a data def's
164
+ * fresh one, a code instance as is). */
165
+ export const resolveMaterialDef = (def: MaterialDef): Promise<Material> => {
166
+ if (def instanceof Material) return Promise.resolve(def)
167
+ if (def instanceof MaterialHandle) return def.load()
168
+ return createMaterialFromData(def)
169
+ }
170
+
171
+ /** Resolve and assign a `material:` value to one slot of a node (tracking handle users). */
172
+ export const assignMaterialDef = async (node: Node, slot: number, def: MaterialDef): Promise<Material> => {
173
+ if (def instanceof MaterialHandle) return def._applyTo(node, slot)
174
+ const m = await resolveMaterialDef(def)
175
+ node.setMaterial(m, slot)
176
+ return m
177
+ }
178
+
179
+ /**
180
+ * Define a material as data — the default export of a `*.material.ts` file. Import it into scene
181
+ * files (`material: handle`) or code (`const m = await handle.load()`).
182
+ */
183
+ export const defineMaterial = (def: MaterialData): MaterialHandle => {
184
+ const handle = new MaterialHandle(def)
185
+ const g = globalThis as unknown as { [EDIT_FLAG]?: boolean, __lecodesMaterialHandles?: MaterialHandle[] }
186
+ if (g[EDIT_FLAG]) (g.__lecodesMaterialHandles ??= []).push(handle)
187
+ return handle
188
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The project's `app.json` subset the server runtime reads (name, auth providers).
3
+ *
4
+ * This module is a placeholder the COMPILER REPLACES: `compileServerBundle({ app })` puts a module with the
5
+ * real values at this same path in chisel's file map (project files win over SDK files), so every SDK module
6
+ * that imports `app` gets the project's config as a plain dependency — no globals, no evaluation-order games.
7
+ * Outside a compiled bundle (tests, tooling) `globalThis.__lecodesApp` can stand in.
8
+ */
9
+ export type AuthProvider = "email" | "google" | "apple" | "yandex" | "vk"
10
+ export type AppConfig = { name?: string, auth?: { providers?: AuthProvider[] } }
11
+
12
+ export const app: AppConfig = {}
@@ -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
+ }