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.
- package/README.md +114 -0
- package/dist/global.d.ts +503 -0
- package/dist/host.d.ts +61 -0
- package/dist/inject.js +4730 -0
- package/dist/types/animate/animate.d.ts +20 -0
- package/dist/types/animate/bezier.d.ts +2 -0
- package/dist/types/animate/easings.d.ts +21 -0
- package/dist/types/canvas/Canvas.d.ts +168 -0
- package/dist/types/core/Aspect.d.ts +171 -0
- package/dist/types/core/InspectorUI.d.ts +88 -0
- package/dist/types/core/StateMachine.d.ts +81 -0
- package/dist/types/core/color.d.ts +13 -0
- package/dist/types/core/compWrite.d.ts +11 -0
- package/dist/types/core/events.d.ts +11 -0
- package/dist/types/core/fields.d.ts +58 -0
- package/dist/types/core/registry.d.ts +7 -0
- package/dist/types/core/signals.d.ts +29 -0
- package/dist/types/core/time.d.ts +31 -0
- package/dist/types/g2/Camera2D.d.ts +19 -0
- package/dist/types/g2/CharacterController2D.d.ts +134 -0
- package/dist/types/g2/Node2D.d.ts +91 -0
- package/dist/types/g2/OneWay2D.d.ts +24 -0
- package/dist/types/g2/Physics2D.d.ts +147 -0
- package/dist/types/g2/Scene2D.d.ts +72 -0
- package/dist/types/g2/Shape2D.d.ts +58 -0
- package/dist/types/g2/Sprite.d.ts +51 -0
- package/dist/types/g2/SpriteAnimation.d.ts +50 -0
- package/dist/types/g2/SpriteSheet.d.ts +69 -0
- package/dist/types/g2/Texture2D.d.ts +16 -0
- package/dist/types/g2/Tilemap.d.ts +20 -0
- package/dist/types/g2/Tileset.d.ts +32 -0
- package/dist/types/g2/Trigger2D.d.ts +20 -0
- package/dist/types/g2/autotile.d.ts +133 -0
- package/dist/types/g2/cells.d.ts +14 -0
- package/dist/types/g2/defineScene2d.d.ts +141 -0
- package/dist/types/g2/groups2d.d.ts +16 -0
- package/dist/types/g2/loop.d.ts +4 -0
- package/dist/types/g2/scenarios2d.d.ts +30 -0
- package/dist/types/g2/touch.d.ts +2 -0
- package/dist/types/gl/Camera.d.ts +79 -0
- package/dist/types/gl/CameraPlace.d.ts +26 -0
- package/dist/types/gl/CharacterController.d.ts +98 -0
- package/dist/types/gl/Gearbox.d.ts +86 -0
- package/dist/types/gl/Geometry.d.ts +39 -0
- package/dist/types/gl/IK.d.ts +53 -0
- package/dist/types/gl/InstancedMesh.d.ts +46 -0
- package/dist/types/gl/Light.d.ts +73 -0
- package/dist/types/gl/Lightmap.d.ts +85 -0
- package/dist/types/gl/Locomotion.d.ts +279 -0
- package/dist/types/gl/Material.d.ts +100 -0
- package/dist/types/gl/Mesh.d.ts +33 -0
- package/dist/types/gl/Model.d.ts +53 -0
- package/dist/types/gl/NavAgent.d.ts +99 -0
- package/dist/types/gl/NavMesh.d.ts +169 -0
- package/dist/types/gl/Node.d.ts +99 -0
- package/dist/types/gl/Noise.d.ts +20 -0
- package/dist/types/gl/Particles.d.ts +277 -0
- package/dist/types/gl/Physics.d.ts +91 -0
- package/dist/types/gl/Plane.d.ts +19 -0
- package/dist/types/gl/Ragdoll.d.ts +86 -0
- package/dist/types/gl/Ray.d.ts +8 -0
- package/dist/types/gl/Scene.d.ts +221 -0
- package/dist/types/gl/Shape.d.ts +122 -0
- package/dist/types/gl/Terrain.d.ts +280 -0
- package/dist/types/gl/Texture.d.ts +27 -0
- package/dist/types/gl/Trigger.d.ts +10 -0
- package/dist/types/gl/Vehicle.d.ts +191 -0
- package/dist/types/gl/Wheel.d.ts +95 -0
- package/dist/types/gl/animation/AnimationClip.d.ts +60 -0
- package/dist/types/gl/animation/Animator.d.ts +219 -0
- package/dist/types/gl/animation/Layer.d.ts +31 -0
- package/dist/types/gl/animation/Loop.d.ts +17 -0
- package/dist/types/gl/animation/Playback.d.ts +36 -0
- package/dist/types/gl/animation/core.d.ts +367 -0
- package/dist/types/gl/controls.d.ts +19 -0
- package/dist/types/gl/physicsEvents.d.ts +1 -0
- package/dist/types/gl/scenarios.d.ts +98 -0
- package/dist/types/gl/state.d.ts +5 -0
- package/dist/types/gl/terrainMesh.d.ts +46 -0
- package/dist/types/gl/touch.d.ts +2 -0
- package/dist/types/inject.d.ts +123 -0
- package/dist/types/math/Mathf.d.ts +39 -0
- package/dist/types/math/mat4.d.ts +66 -0
- package/dist/types/math/quat.d.ts +53 -0
- package/dist/types/math/vec.d.ts +129 -0
- package/dist/types/net/codec.d.ts +25 -0
- package/dist/types/net/core.d.ts +106 -0
- package/dist/types/net/index.d.ts +80 -0
- package/dist/types/net/replication.d.ts +101 -0
- package/dist/types/plugins/camera.d.ts +25 -0
- package/dist/types/plugins/geolocation.d.ts +39 -0
- package/dist/types/plugins/oauth.d.ts +25 -0
- package/dist/types/plugins/permission.d.ts +1 -0
- package/dist/types/plugins/push.d.ts +46 -0
- package/dist/types/plugins/qr.d.ts +21 -0
- package/dist/types/plugins/service.d.ts +33 -0
- package/dist/types/runtime/app.d.ts +67 -0
- package/dist/types/runtime/appEvents.d.ts +7 -0
- package/dist/types/runtime/channel.d.ts +1 -0
- package/dist/types/runtime/clipboard.d.ts +7 -0
- package/dist/types/runtime/datetime.d.ts +80 -0
- package/dist/types/runtime/device.d.ts +126 -0
- package/dist/types/runtime/fetch.d.ts +31 -0
- package/dist/types/runtime/files.d.ts +33 -0
- package/dist/types/runtime/input.d.ts +121 -0
- package/dist/types/runtime/media.d.ts +46 -0
- package/dist/types/runtime/misc.d.ts +11 -0
- package/dist/types/runtime/net.d.ts +15 -0
- package/dist/types/runtime/rpc.d.ts +44 -0
- package/dist/types/runtime/service.d.ts +1 -0
- package/dist/types/runtime/share.d.ts +2 -0
- package/dist/types/runtime/storage.d.ts +5 -0
- package/dist/types/runtime/touch.d.ts +56 -0
- package/dist/types/scene/defineScene.d.ts +296 -0
- package/dist/types/scene/editorPlugins.d.ts +82 -0
- package/dist/types/scene/gizmos.d.ts +56 -0
- package/dist/types/scene/grammar.d.ts +56 -0
- package/dist/types/scene/level.d.ts +98 -0
- package/dist/types/scene/material.d.ts +76 -0
- package/dist/types/server/auth/appConfig.d.ts +16 -0
- package/dist/types/server/auth/global.d.ts +56 -0
- package/dist/types/server/auth/models.d.ts +164 -0
- package/dist/types/server/auth/types.d.ts +56 -0
- package/dist/types/server/channel.d.ts +28 -0
- package/dist/types/server/context.d.ts +27 -0
- package/dist/types/server/db/defineDb.d.ts +36 -0
- package/dist/types/server/db/fields.d.ts +146 -0
- package/dist/types/server/db/index.d.ts +6 -0
- package/dist/types/server/db/marci/query.d.ts +326 -0
- package/dist/types/server/db/types.d.ts +163 -0
- package/dist/types/server/errors.d.ts +8 -0
- package/dist/types/server/inject.d.ts +5 -0
- package/dist/types/ui/NativeView.d.ts +38 -0
- package/dist/types/ui/UI.d.ts +27 -0
- package/dist/types/ui/UIBottomSheet.d.ts +71 -0
- package/dist/types/ui/UIButton.d.ts +30 -0
- package/dist/types/ui/UIContainer.d.ts +33 -0
- package/dist/types/ui/UIImage.d.ts +35 -0
- package/dist/types/ui/UIInput.d.ts +110 -0
- package/dist/types/ui/UIModal.d.ts +70 -0
- package/dist/types/ui/UINode.d.ts +363 -0
- package/dist/types/ui/UIPager.d.ts +123 -0
- package/dist/types/ui/UIPopover.d.ts +39 -0
- package/dist/types/ui/UIScreen.d.ts +57 -0
- package/dist/types/ui/UIScrollable.d.ts +40 -0
- package/dist/types/ui/UISpacer.d.ts +9 -0
- package/dist/types/ui/UITabs.d.ts +96 -0
- package/dist/types/ui/UIText.d.ts +19 -0
- package/dist/types/ui/UIVideo.d.ts +28 -0
- package/dist/types/ui/UIVirtualizedList.d.ts +97 -0
- package/dist/types/ui/UIWidget.d.ts +65 -0
- package/dist/types/ui/fonts.d.ts +9 -0
- package/dist/types/ui/presentable.d.ts +68 -0
- package/dist/types/ui/router.d.ts +40 -0
- package/dist/types/ui/theme.d.ts +26 -0
- package/dist/types.json +1 -0
- package/package.json +46 -0
- package/prompts/2d.md +395 -0
- package/prompts/3d-scene-files.md +113 -0
- package/prompts/3d-scene.md +119 -0
- package/prompts/3d.md +303 -0
- package/prompts/README.md +142 -0
- package/prompts/ar.md +109 -0
- package/prompts/canvas.md +27 -0
- package/prompts/compose.ts +46 -0
- package/prompts/concept.md +49 -0
- package/prompts/core-design.md +75 -0
- package/prompts/core.md +264 -0
- package/prompts/design.md +218 -0
- package/prompts/dist/2d-game.md +1530 -0
- package/prompts/dist/3d-app.md +1672 -0
- package/prompts/dist/ar-app.md +1548 -0
- package/prompts/dist/concept.md +49 -0
- package/prompts/dist/design.md +498 -0
- package/prompts/dist/ui-app.md +1134 -0
- package/prompts/index.md +37 -0
- package/prompts/intake-prompt.md +33 -0
- package/prompts/namer-prompt.md +12 -0
- package/prompts/router-prompt.md +50 -0
- package/prompts/select.ts +111 -0
- package/prompts/ui-design.md +203 -0
- package/prompts/ui.md +803 -0
- package/src/animate/animate.ts +238 -0
- package/src/animate/bezier.ts +138 -0
- package/src/animate/easings.ts +126 -0
- package/src/bridges.d.ts +1345 -0
- package/src/canvas/Canvas.ts +305 -0
- package/src/compile/__tests__/assetIconMacro.test.ts +219 -0
- package/src/compile/__tests__/assetMacro.test.ts +74 -0
- package/src/compile/__tests__/assetName.test.ts +55 -0
- package/src/compile/__tests__/compile.test.ts +287 -0
- package/src/compile/__tests__/detectEntry.test.ts +132 -0
- package/src/compile/__tests__/fontMacro.test.ts +199 -0
- package/src/compile/aspectMacro.ts +86 -0
- package/src/compile/assetIconMacro.ts +384 -0
- package/src/compile/assetMacro.ts +146 -0
- package/src/compile/assetName.ts +50 -0
- package/src/compile/bundler.ts +287 -0
- package/src/compile/compileProject.ts +127 -0
- package/src/compile/detectEntry.ts +128 -0
- package/src/compile/fontMacro.ts +459 -0
- package/src/compile/fontRegistry.ts +78 -0
- package/src/compile/header.ts +67 -0
- package/src/compile/index.ts +100 -0
- package/src/compile/libraryImports.ts +52 -0
- package/src/compile/liteMaterial.ts +247 -0
- package/src/compile/sceneEditor.ts +88 -0
- package/src/compile/serverSplit.ts +233 -0
- package/src/compile/serverTypes.ts +227 -0
- package/src/compile/sfnt.ts +98 -0
- package/src/compile/shaderSchema.ts +202 -0
- package/src/compile/shaderTargets.ts +77 -0
- package/src/compile/sourcemap.ts +25 -0
- package/src/core/Aspect.ts +568 -0
- package/src/core/InspectorUI.ts +212 -0
- package/src/core/StateMachine.ts +308 -0
- package/src/core/__tests__/stateMachine.test.ts +132 -0
- package/src/core/color.ts +66 -0
- package/src/core/compWrite.ts +42 -0
- package/src/core/events.ts +38 -0
- package/src/core/fields.ts +120 -0
- package/src/core/registry.ts +23 -0
- package/src/core/signals.ts +277 -0
- package/src/core/time.ts +84 -0
- package/src/g2/Camera2D.ts +40 -0
- package/src/g2/CharacterController2D.ts +276 -0
- package/src/g2/Node2D.ts +267 -0
- package/src/g2/OneWay2D.ts +66 -0
- package/src/g2/Physics2D.ts +346 -0
- package/src/g2/Scene2D.ts +209 -0
- package/src/g2/Shape2D.ts +259 -0
- package/src/g2/Sprite.ts +89 -0
- package/src/g2/SpriteAnimation.ts +171 -0
- package/src/g2/SpriteSheet.ts +166 -0
- package/src/g2/Texture2D.ts +47 -0
- package/src/g2/Tilemap.ts +41 -0
- package/src/g2/Tileset.ts +71 -0
- package/src/g2/Trigger2D.ts +77 -0
- package/src/g2/autotile.ts +433 -0
- package/src/g2/cells.ts +91 -0
- package/src/g2/defineScene2d.ts +381 -0
- package/src/g2/groups2d.ts +106 -0
- package/src/g2/loop.ts +50 -0
- package/src/g2/scenarios2d.ts +69 -0
- package/src/g2/touch.ts +83 -0
- package/src/gl/Camera.ts +160 -0
- package/src/gl/CameraPlace.ts +52 -0
- package/src/gl/CharacterController.ts +238 -0
- package/src/gl/Gearbox.ts +212 -0
- package/src/gl/Geometry.ts +279 -0
- package/src/gl/IK.ts +193 -0
- package/src/gl/InstancedMesh.ts +132 -0
- package/src/gl/Light.ts +135 -0
- package/src/gl/Lightmap.ts +402 -0
- package/src/gl/Locomotion.ts +498 -0
- package/src/gl/Material.ts +279 -0
- package/src/gl/Mesh.ts +83 -0
- package/src/gl/Model.ts +124 -0
- package/src/gl/NavAgent.ts +337 -0
- package/src/gl/NavMesh.ts +397 -0
- package/src/gl/Node.ts +350 -0
- package/src/gl/Noise.ts +30 -0
- package/src/gl/Particles.ts +676 -0
- package/src/gl/Physics.ts +222 -0
- package/src/gl/Plane.ts +53 -0
- package/src/gl/Ragdoll.ts +270 -0
- package/src/gl/Ray.ts +16 -0
- package/src/gl/Scene.ts +505 -0
- package/src/gl/Shape.ts +409 -0
- package/src/gl/Terrain.ts +1092 -0
- package/src/gl/Texture.ts +63 -0
- package/src/gl/Trigger.ts +45 -0
- package/src/gl/Vehicle.ts +473 -0
- package/src/gl/Wheel.ts +240 -0
- package/src/gl/animation/AnimationClip.ts +204 -0
- package/src/gl/animation/Animator.ts +329 -0
- package/src/gl/animation/Layer.ts +39 -0
- package/src/gl/animation/Loop.ts +31 -0
- package/src/gl/animation/Playback.ts +52 -0
- package/src/gl/animation/core.ts +670 -0
- package/src/gl/controls.ts +95 -0
- package/src/gl/physicsEvents.ts +20 -0
- package/src/gl/scenarios.ts +291 -0
- package/src/gl/state.ts +6 -0
- package/src/gl/terrainMesh.ts +219 -0
- package/src/gl/touch.ts +68 -0
- package/src/host.d.ts +61 -0
- package/src/inject.ts +203 -0
- package/src/math/Mathf.ts +118 -0
- package/src/math/mat4.ts +278 -0
- package/src/math/quat.ts +232 -0
- package/src/math/vec.ts +255 -0
- package/src/net/codec.ts +119 -0
- package/src/net/core.ts +384 -0
- package/src/net/index.ts +181 -0
- package/src/net/replication.ts +622 -0
- package/src/plugins/camera.ts +81 -0
- package/src/plugins/geolocation.ts +123 -0
- package/src/plugins/oauth.ts +61 -0
- package/src/plugins/permission.ts +7 -0
- package/src/plugins/push.ts +132 -0
- package/src/plugins/qr.ts +73 -0
- package/src/plugins/service.ts +47 -0
- package/src/runtime/app.ts +101 -0
- package/src/runtime/appEvents.ts +54 -0
- package/src/runtime/channel.ts +61 -0
- package/src/runtime/clipboard.ts +20 -0
- package/src/runtime/datetime.ts +329 -0
- package/src/runtime/device.ts +293 -0
- package/src/runtime/fetch.ts +77 -0
- package/src/runtime/files.ts +108 -0
- package/src/runtime/input.ts +175 -0
- package/src/runtime/media.ts +111 -0
- package/src/runtime/misc.ts +16 -0
- package/src/runtime/net.ts +36 -0
- package/src/runtime/rpc.ts +218 -0
- package/src/runtime/service.ts +83 -0
- package/src/runtime/share.ts +9 -0
- package/src/runtime/storage.ts +13 -0
- package/src/runtime/touch.ts +76 -0
- package/src/scene/defineScene.ts +1396 -0
- package/src/scene/editorPlugins.ts +110 -0
- package/src/scene/gizmos.ts +148 -0
- package/src/scene/grammar.ts +120 -0
- package/src/scene/level.ts +296 -0
- package/src/scene/material.ts +188 -0
- package/src/server/auth/appConfig.ts +12 -0
- package/src/server/auth/global.ts +80 -0
- package/src/server/auth/host.ts +318 -0
- package/src/server/auth/models.ts +83 -0
- package/src/server/auth/types.ts +50 -0
- package/src/server/channel.ts +56 -0
- package/src/server/context.ts +36 -0
- package/src/server/db/defineDb.ts +237 -0
- package/src/server/db/fields.ts +132 -0
- package/src/server/db/httpTransport.ts +93 -0
- package/src/server/db/index.ts +7 -0
- package/src/server/db/marci/query.ts +412 -0
- package/src/server/db/types.ts +202 -0
- package/src/server/errors.ts +12 -0
- package/src/server/host.ts +74 -0
- package/src/server/inject.ts +13 -0
- package/src/server/runtime.ts +133 -0
- package/src/server/validate.ts +87 -0
- package/src/ui/NativeView.ts +142 -0
- package/src/ui/UI.ts +39 -0
- package/src/ui/UIBottomSheet.ts +139 -0
- package/src/ui/UIButton.ts +101 -0
- package/src/ui/UIContainer.ts +60 -0
- package/src/ui/UIImage.ts +83 -0
- package/src/ui/UIInput.ts +185 -0
- package/src/ui/UIModal.ts +139 -0
- package/src/ui/UINode.ts +830 -0
- package/src/ui/UIPager.ts +362 -0
- package/src/ui/UIPopover.ts +100 -0
- package/src/ui/UIScreen.ts +123 -0
- package/src/ui/UIScrollable.ts +87 -0
- package/src/ui/UISpacer.ts +14 -0
- package/src/ui/UITabs.ts +236 -0
- package/src/ui/UIText.ts +51 -0
- package/src/ui/UIVideo.ts +88 -0
- package/src/ui/UIVirtualizedList.ts +241 -0
- package/src/ui/UIWidget.ts +127 -0
- package/src/ui/fonts.ts +13 -0
- package/src/ui/presentable.ts +117 -0
- package/src/ui/router.ts +132 -0
- 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
|
+
}
|