elanous 0.2.5 → 0.2.7
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/.env.example +10 -7
- package/README.md +9 -4
- package/apps/pwa/out/404/index.html +1 -1
- package/apps/pwa/out/404.html +1 -1
- package/apps/pwa/out/_next/static/chunks/1105-c473ed46272fba79.js +1 -0
- package/apps/pwa/out/_next/static/chunks/1201-09613bce624467c0.js +1 -0
- package/apps/pwa/out/_next/static/chunks/1377-1efeffbbac0963ff.js +1 -0
- package/apps/pwa/out/_next/static/chunks/1470.98a6ab61ccb4131c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/216-5acd01cbde4acda7.js +1 -0
- package/apps/pwa/out/_next/static/chunks/2330-c2f07246c8788af1.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{5391-8793f88b03b30161.js → 2375-cb6debea56cce4fb.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/{1751.50b96ea9c37b49b6.js → 2415.ed6428ff334c7ce2.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/2929-617f1e66a55be184.js +1 -0
- package/apps/pwa/out/_next/static/chunks/2977-e38442e86289f1d3.js +1 -0
- package/apps/pwa/out/_next/static/chunks/2982-2a2df543be3d2e5a.js +25 -0
- package/apps/pwa/out/_next/static/chunks/{4daa2798-1363d725c79f3593.js → 2ea96958-e8c7e112de50b067.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/3403.4bc97b4066945a94.js +5 -0
- package/apps/pwa/out/_next/static/chunks/3441-a1dd4acfeda14488.js +1 -0
- package/apps/pwa/out/_next/static/chunks/371-4ad6af55dad02ced.js +1 -0
- package/apps/pwa/out/_next/static/chunks/4114-3e63117452378807.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{8617.465ca22a319c95b3.js → 4323.c5dfb6f503304775.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/4836-2eba6079f31b88d4.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{8918-482b30ce2a8ebf4c.js → 5080-1613752891fcadb4.js} +3 -3
- package/apps/pwa/out/_next/static/chunks/5609-012bb3557211f07e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{5348-bbf838e119661eee.js → 5791-bb82743fa6299562.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/{9704-3221c102623c8d73.js → 6062-73e68343a9814f6a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/{4440-18bb160d95819f4e.js → 6090-85a3f05f954e5697.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/6284-69db415193d5f8bd.js +1 -0
- package/apps/pwa/out/_next/static/chunks/6320-baac91d68843a9a6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/6416-505ba707cacafbba.js +1 -0
- package/apps/pwa/out/_next/static/chunks/6725-68ab7cc0b1faf00d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/6998-a654fe5c58271600.js +1 -0
- package/apps/pwa/out/_next/static/chunks/7647.2fcd30d894d5ee30.js +1 -0
- package/apps/pwa/out/_next/static/chunks/7673-2eda1507d38dadb8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/7990-e12efba616f46e03.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{f6f3850d-7f324c6566002113.js → 8046cadf-211ca3c0e00f2b72.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/8067-f74bf14d67ad98f1.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8142-744998d65d7bb17e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8519-f6b5d8a5833a7603.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8619-4365e03a2862830f.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8894-e92491a164192cc1.js +1 -0
- package/apps/pwa/out/_next/static/chunks/920-f7dceb78f1c585ef.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9454-a3e0953307216c58.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9651-3cd291a6fa1370d2.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9740-67051141115ce775.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9804-64c02c3c127d5014.js +1 -0
- package/apps/pwa/out/_next/static/chunks/ae787e4c-f2998de7f1ba72a4.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/_not-found/page-859cea1570b6227a.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-d5ebde04844689e6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-f89e9a303515a033.js → page-10ed24f413ed66e9.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/board/{page-4f403cd060442c95.js → page-211810b6c93ab66a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/botlab/{page-0ac5d053f0444d21.js → page-624f7a2f89b60c8f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/page-5a5fb385a4df0ffe.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/chat/page-80497e93a878ddc5.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/consult/page-7457f52683562161.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/control/page-cee86394de407aa6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-4ff8edab44196b8c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/editor/page-92e4f44428d5e290.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/exec/page-b7dcf5d48a854b76.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/field/page-51480757e7d68b92.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/healthz/route-859cea1570b6227a.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/intake/page-2a05dfe80c8bdf16.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/layout-d9979633aa82f8e6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/live/page-30533117beb53625.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/market/page-f7857826c2f97ad7.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-07267f1391b96aba.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-bccce0babceae30f.js → page-55d50e9b0006a91d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-9699b1786be6171d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-43dd772aee8af75f.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/page-a7e37af7d0399cb8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/reflection/page-973ab7af59c718b7.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-a9f8d3865b91e436.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/sessions/page-15e5a15db84c56aa.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-a2e572e4511f2a16.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/settings/page-dd107bf1c7d52a90.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-d42b99cdfb9f27c6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/layout-859cea1570b6227a.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/page-2764ff40dad5a995.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/share/page-c5f1152db116ff96.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-f933bca81ea38a6f.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-c1c95739c0bff3b8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/term/page-a7aaffcf771662d8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/trace/page-71d18eec33b9c667.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/vault/page-56adbe1a1f1379bd.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-2c4e59079ee357ac.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-a81f113f3ef89d72.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workspace/page-5d422f38c052967e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-fc27b00e20733146.js +1 -0
- package/apps/pwa/out/_next/static/chunks/framework-ea806bb57f2b8923.js +1 -0
- package/apps/pwa/out/_next/static/chunks/main-8d2302399c2c8f87.js +1 -0
- package/apps/pwa/out/_next/static/chunks/main-app-6870f30ea99f8346.js +1 -0
- package/apps/pwa/out/_next/static/chunks/pages/_app-b10928c60f0b8488.js +1 -0
- package/apps/pwa/out/_next/static/chunks/pages/_error-689039227d443fab.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{webpack-2bf59c720fe9542c.js → webpack-e5bea36f183b25e6.js} +1 -1
- package/apps/pwa/out/_next/static/css/f537bdb19c69306a.css +1 -0
- package/apps/pwa/out/_next/static/keajW7DDyghEs1y8oJe6T/_buildManifest.js +1 -0
- package/apps/pwa/out/approvals/index.html +1 -1
- package/apps/pwa/out/approvals/index.txt +22 -22
- package/apps/pwa/out/autopilot/index.html +1 -1
- package/apps/pwa/out/autopilot/index.txt +22 -22
- package/apps/pwa/out/board/index.html +1 -1
- package/apps/pwa/out/board/index.txt +22 -22
- package/apps/pwa/out/botlab/index.html +1 -1
- package/apps/pwa/out/botlab/index.txt +21 -21
- package/apps/pwa/out/bots/index.html +1 -1
- package/apps/pwa/out/bots/index.txt +21 -21
- package/apps/pwa/out/chat/index.html +1 -1
- package/apps/pwa/out/chat/index.txt +21 -21
- package/apps/pwa/out/consult/index.html +1 -0
- package/apps/pwa/out/consult/index.txt +32 -0
- package/apps/pwa/out/control/index.html +1 -1
- package/apps/pwa/out/control/index.txt +22 -22
- package/apps/pwa/out/design-check/index.html +1 -1
- package/apps/pwa/out/design-check/index.txt +22 -22
- package/apps/pwa/out/editor/index.html +1 -1
- package/apps/pwa/out/editor/index.txt +22 -22
- package/apps/pwa/out/exec/index.html +1 -0
- package/apps/pwa/out/exec/index.txt +32 -0
- package/apps/pwa/out/field/index.html +1 -0
- package/apps/pwa/out/field/index.txt +32 -0
- package/apps/pwa/out/healthz +1 -1
- package/apps/pwa/out/icons/elanous-16.png +0 -0
- package/apps/pwa/out/icons/elanous-180.png +0 -0
- package/apps/pwa/out/icons/elanous-192.png +0 -0
- package/apps/pwa/out/icons/elanous-32.png +0 -0
- package/apps/pwa/out/icons/elanous-512.png +0 -0
- package/apps/pwa/out/index.html +1 -1
- package/apps/pwa/out/index.txt +21 -21
- package/apps/pwa/out/intake/index.html +1 -1
- package/apps/pwa/out/intake/index.txt +22 -22
- package/apps/pwa/out/live/index.html +1 -1
- package/apps/pwa/out/live/index.txt +22 -22
- package/apps/pwa/out/market/index.html +1 -1
- package/apps/pwa/out/market/index.txt +21 -21
- package/apps/pwa/out/missions/placeholder/index.html +1 -1
- package/apps/pwa/out/missions/placeholder/index.txt +21 -21
- package/apps/pwa/out/morning/index.html +1 -1
- package/apps/pwa/out/morning/index.txt +22 -22
- package/apps/pwa/out/observatory/index.html +1 -1
- package/apps/pwa/out/observatory/index.txt +22 -22
- package/apps/pwa/out/reflection/index.html +1 -1
- package/apps/pwa/out/reflection/index.txt +22 -22
- package/apps/pwa/out/scheduler/index.html +1 -1
- package/apps/pwa/out/scheduler/index.txt +22 -22
- package/apps/pwa/out/sessions/index.html +1 -1
- package/apps/pwa/out/sessions/index.txt +21 -21
- package/apps/pwa/out/settings/devices/index.html +1 -1
- package/apps/pwa/out/settings/devices/index.txt +22 -22
- package/apps/pwa/out/settings/index.html +1 -1
- package/apps/pwa/out/settings/index.txt +22 -22
- package/apps/pwa/out/setup/done/index.html +1 -1
- package/apps/pwa/out/setup/done/index.txt +22 -22
- package/apps/pwa/out/setup/index.html +1 -1
- package/apps/pwa/out/setup/index.txt +22 -22
- package/apps/pwa/out/share/index.html +1 -1
- package/apps/pwa/out/share/index.txt +22 -22
- package/apps/pwa/out/showroom/index.html +1 -1
- package/apps/pwa/out/showroom/index.txt +21 -21
- package/apps/pwa/out/tasks/index.html +1 -1
- package/apps/pwa/out/tasks/index.txt +22 -22
- package/apps/pwa/out/term/index.html +1 -1
- package/apps/pwa/out/term/index.txt +22 -22
- package/apps/pwa/out/trace/index.html +1 -1
- package/apps/pwa/out/trace/index.txt +22 -22
- package/apps/pwa/out/vault/index.html +1 -1
- package/apps/pwa/out/vault/index.txt +22 -22
- package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
- package/apps/pwa/out/workflows/chat-ui/index.txt +22 -22
- package/apps/pwa/out/workflows/index.html +1 -1
- package/apps/pwa/out/workflows/index.txt +22 -22
- package/apps/pwa/out/workspace/index.html +1 -1
- package/apps/pwa/out/workspace/index.txt +22 -22
- package/apps/pwa/out/worktrees/index.html +1 -1
- package/apps/pwa/out/worktrees/index.txt +22 -22
- package/bin/elanous.cjs +107 -0
- package/graphs/field/field-feed.yaml +24 -0
- package/graphs/field/recipes.yaml +12 -0
- package/graphs/landing/landing-heal.yaml +3 -1
- package/graphs/landing/recipes.yaml +3 -0
- package/graphs/release/recipes.yaml +13 -0
- package/graphs/release/release-loop.yaml +13 -4
- package/package.json +5 -5
- package/plugins/card-followup/README.md +42 -0
- package/plugins/card-followup/examples/card-sample.json +7 -0
- package/plugins/card-followup/graphs/card-followup.yaml +18 -0
- package/plugins/card-followup/graphs/recipes.yaml +16 -0
- package/plugins/card-followup/graphs/run-step.ts +276 -0
- package/plugins/card-followup/graphs/sales-strategy.ts +173 -0
- package/plugins/card-followup/plugin.json +25 -0
- package/plugins/doc-draft/README.md +13 -0
- package/plugins/doc-draft/examples/brief-sample.json +8 -0
- package/plugins/doc-draft/graphs/doc-draft.yaml +14 -0
- package/plugins/doc-draft/graphs/recipes.yaml +9 -0
- package/plugins/doc-draft/graphs/run-step.ts +113 -0
- package/plugins/doc-draft/plugin.json +24 -0
- package/plugins/geo-check/README.md +15 -0
- package/plugins/geo-check/examples/brand-sample.json +8 -0
- package/plugins/geo-check/graphs/geo-check.yaml +20 -0
- package/plugins/geo-check/graphs/recipes.yaml +18 -0
- package/plugins/geo-check/graphs/run-step.ts +198 -0
- package/plugins/geo-check/plugin.json +24 -0
- package/plugins/job-coach/.codex-plugin/plugin.json +1 -1
- package/plugins/job-coach/README.md +31 -1
- package/plugins/job-coach/graphs/run-step.ts +2 -2
- package/plugins/job-coach/plugin.json +1 -1
- package/plugins/job-coach/plugin.ts +1 -1
- package/scripts/affinity/endcard.sh +3 -2
- package/scripts/affinity/reframe.sh +3 -1
- package/scripts/ci-public-export-leak-gate.ts +48 -0
- package/scripts/codex-quota-alert.ts +122 -258
- package/scripts/coord-tracks.json +4 -4
- package/scripts/daemon-port-baseline.txt +1 -2
- package/scripts/field-feed/deliver-node.ts +52 -0
- package/scripts/field-feed/feed-node.ts +67 -0
- package/scripts/field-feed/lib.ts +162 -0
- package/scripts/field-feed/reel-node.ts +24 -0
- package/scripts/field-feed/start.ts +62 -0
- package/scripts/fixtures/vflow-sample.ndjson +6 -0
- package/scripts/install.sh +92 -13
- package/scripts/landing-heal/clean-checkout.ts +114 -0
- package/scripts/landing-heal/run-mission.ts +38 -3
- package/scripts/lib/android-test-device.ts +32 -0
- package/scripts/lib/pwa-scenarios/scenarios.ts +2 -1
- package/scripts/public-export.ts +8 -4
- package/scripts/release-loop/baseline-from-logs.ts +92 -0
- package/scripts/release-loop/checklist-gate-node.ts +35 -0
- package/scripts/release-loop/env-known-failures.ts +31 -0
- package/scripts/release-loop/gate-diff.ts +29 -0
- package/scripts/release-loop/gate-node.ts +124 -29
- package/scripts/release-loop/known-issues-node.ts +159 -0
- package/scripts/release-loop/npm-publish-node.ts +118 -0
- package/scripts/release-loop/ops-upgrade-node.ts +87 -0
- package/scripts/release-loop/release-readiness.ts +58 -0
- package/scripts/release-loop/shard-plan.ts +47 -0
- package/scripts/release-loop/unattended-release.ts +93 -0
- package/scripts/ux/measure-first-text.ts +102 -0
- package/scripts/ux/record-page.ts +4 -1
- package/scripts/vflow-collect.ts +20 -7
- package/scripts/vflow-search.ts +73 -0
- package/scripts/video-free-line-test-walker.ts +36 -0
- package/scripts/video-pipeline.ts +2 -2
- package/skills/explainer-video/SKILL.md +24 -3
- package/skills/explainer-video/engine/brand/elanous-mark-on-dark.svg +1 -1
- package/skills/explainer-video/engine/build.mjs +2 -2
- package/skills/explainer-video/engine/reel.mjs +235 -0
- package/skills/explainer-video/engine/reel.sh +9 -0
- package/skills/explainer-video/engine/vo.ts +58 -16
- package/skills/explainer-video/examples/elanous-0.2.5/script.json +1 -1
- package/skills/grill-me/SKILL.md +1 -1
- package/skills/omni-digest/docs/diagrams/noncode-terminal-promotion-path-2026-08-13.mmd +1 -1
- package/src/a2a/server.ts +2 -0
- package/src/acp/elanous-aware-peers.ts +30 -0
- package/src/acp/server.ts +91 -15
- package/src/agent/monad-agent-turn.ts +25 -6
- package/src/agent/shared-app-tools.ts +5 -3
- package/src/agent-substrate/review-intent.ts +6 -2
- package/src/agent-substrate/shard-boundary-intent.ts +70 -0
- package/src/auth/github-app-token.ts +4 -1
- package/src/autopilot/mission-origin.ts +20 -4
- package/src/autopilot/mission-phase-track.ts +11 -1
- package/src/autopilot/track-agent-shadow.ts +151 -0
- package/src/autopilot/track-agent.ts +26 -6
- package/src/autopilot/track-courier.ts +2 -2
- package/src/boot/acp-server-help.ts +16 -0
- package/src/boot/daemon-prompt-turn.ts +31 -10
- package/src/boot/daemon-tools/index.ts +7 -1
- package/src/budget/codex-credit-pace.ts +84 -0
- package/src/budget/codex-credit-plan.ts +154 -0
- package/src/budget/codex-reset-credit-state.ts +2 -0
- package/src/chat/chat-cli.ts +4 -1
- package/src/chat/short-question.ts +40 -0
- package/src/cli/agent-cli.ts +24 -15
- package/src/cli/ask-bare.ts +42 -0
- package/src/cli/cli-user-error.ts +25 -0
- package/src/cli/codex-credit-plan-view.ts +25 -0
- package/src/cli/dependence-gauge.ts +237 -0
- package/src/cli/doctor-cli.ts +5 -0
- package/src/cli/doctor-modes.ts +48 -0
- package/src/cli/intake-cli.ts +12 -2
- package/src/cli/intake-source-cli.ts +58 -0
- package/src/cli/machine-cli.ts +23 -0
- package/src/cli/msg-cli.ts +137 -0
- package/src/cli/nexus-ios-bind.ts +25 -8
- package/src/cli/phone-link.ts +117 -0
- package/src/cli/plugin-cli.ts +52 -0
- package/src/cli/pr-cli.ts +36 -0
- package/src/cli/private-ledger-commands.ts +31 -0
- package/src/cli/provider-cli.ts +5 -0
- package/src/cli/release-cli.ts +56 -12
- package/src/cli/release-run-if-ready.ts +48 -0
- package/src/cli/setup-cli.ts +27 -6
- package/src/cli/setup-recommend.ts +8 -0
- package/src/cli/usage-cli.ts +11 -1
- package/src/codex/setup.ts +27 -8
- package/src/dashboard/index.ts +1 -1
- package/src/discord.ts +56 -6
- package/src/domains/core-tools.ts +37 -14
- package/src/domains/outbound-alert.ts +55 -16
- package/src/exec-requests/planner.ts +140 -0
- package/src/exec-requests/runner.ts +338 -0
- package/src/exec-requests/store.ts +76 -0
- package/src/field/field-media.ts +250 -0
- package/src/field/field-reel.ts +196 -0
- package/src/grok/credential.ts +7 -5
- package/src/harness/aside-run-status.ts +33 -0
- package/src/harness/browser-verify.ts +48 -17
- package/src/harness/harness-cli-command.ts +25 -3
- package/src/harness/image-load-state.ts +21 -0
- package/src/harness/process-garbage.ts +45 -0
- package/src/index.ts +104 -38
- package/src/instance/leader.ts +13 -1
- package/src/intake-plane/collect-github.ts +5 -3
- package/src/intake-plane/collect-telegram-saved.ts +15 -3
- package/src/intake-plane/discord-seat-work.ts +56 -0
- package/src/intake-plane/intake-sources.ts +202 -0
- package/src/intake-plane/items.ts +28 -12
- package/src/intake-plane/seat-doc-route.ts +207 -0
- package/src/intake-plane/submit-intake-work.ts +9 -1
- package/src/intake-plane/telegram-seat-work.ts +72 -0
- package/src/llm/model-defaults.ts +24 -1
- package/src/llm.ts +5 -4
- package/src/machines/machine-ledger.ts +127 -0
- package/src/market/pack-internal-refs.ts +28 -0
- package/src/market/publish.ts +31 -4
- package/src/maturity/feature-maturity.ts +65 -0
- package/src/model-tier/turn-tier.ts +24 -0
- package/src/msg/msg-store.ts +149 -0
- package/src/native-tool-catalog.ts +2 -1
- package/src/nexus/api/chat-fast-path-config.ts +28 -0
- package/src/nexus/api/consult-requests.ts +117 -0
- package/src/nexus/api/discord-trigger-bot.ts +3 -0
- package/src/nexus/api/exec-requests.ts +51 -0
- package/src/nexus/api/field-uploads.ts +180 -0
- package/src/nexus/api/graph-approvals-feed.ts +140 -0
- package/src/nexus/api/graph-approvals.ts +120 -4
- package/src/nexus/api/harness-api.ts +219 -5
- package/src/nexus/api/http-server.ts +68 -5
- package/src/nexus/api/seat-requests.ts +254 -0
- package/src/nexus/api/trace-run-scan.ts +37 -0
- package/src/nexus/api/trace.ts +19 -3
- package/src/nexus/index.ts +3 -0
- package/src/nexus/setup-status.ts +4 -5
- package/src/notify/llm-budget-alert.ts +125 -0
- package/src/oauth/codex-account-rotation.ts +26 -4
- package/src/oauth/codex-account-store.ts +20 -2
- package/src/oauth/codex-browser-login.ts +173 -0
- package/src/oauth/codex.ts +4 -1
- package/src/oauth/fallback-chain.ts +5 -5
- package/src/oauth/store.ts +42 -5
- package/src/onboarding/continue-here.ts +29 -0
- package/src/onboarding/entry-hints.ts +8 -0
- package/src/onboarding/full-screen-io.ts +35 -3
- package/src/onboarding/help/skills.md +1 -1
- package/src/onboarding/non-interactive.ts +1 -0
- package/src/onboarding/subscription-hint.ts +27 -0
- package/src/onboarding/web-first.ts +83 -0
- package/src/onboarding.ts +230 -23
- package/src/plugins/maker/node-install.ts +92 -0
- package/src/plugins/maker/node-maker.ts +139 -0
- package/src/plugins/maker/plugin-maker.ts +220 -0
- package/src/policy/llm-policy.ts +268 -0
- package/src/prompt-library/session-guidance.ts +7 -1
- package/src/prompt-library/universal-preamble.ts +2 -2
- package/src/relay/channel.ts +184 -0
- package/src/relay/mux.ts +182 -0
- package/src/relay/relay-cli.ts +20 -0
- package/src/relay/relay-server.ts +220 -0
- package/src/relay/test-vectors.json +29 -0
- package/src/release-loop/checklist.ts +36 -9
- package/src/seat-address/seat-address.ts +34 -0
- package/src/self-dev/decompose.ts +1 -0
- package/src/self-dev/dev-pipeline.ts +6 -3
- package/src/self-dev/run-record-mirror.ts +44 -0
- package/src/self-dev/run-store.ts +12 -3
- package/src/self-implement/harness-policy-gates.ts +4 -1
- package/src/self-implement/report-origin.ts +12 -1
- package/src/self-implement/run-ledger-compact.ts +1 -1
- package/src/session/chat.ts +17 -5
- package/src/session/index.ts +23 -10
- package/src/skills/shared-agent-skill-roots.ts +25 -0
- package/src/steward/failure-streak.ts +78 -0
- package/src/steward/steward-cards.ts +24 -0
- package/src/steward/triage-budget.ts +127 -0
- package/src/steward/triage-plan.ts +75 -0
- package/src/steward/triage.ts +244 -42
- package/src/task-cards/card-store.ts +1 -1
- package/src/task-orchestrator/surfaces/pod-bun-cache.ts +18 -0
- package/src/task-orchestrator/surfaces/pod-command-job.ts +12 -3
- package/src/task-orchestrator/surfaces/pod-ledger-collect.ts +11 -5
- package/src/task-orchestrator/surfaces/pod-ledger-events.ts +68 -0
- package/src/task-orchestrator/surfaces/self-implement-pod.ts +78 -4
- package/src/telegram.ts +145 -2
- package/src/tool-runtime/elanous-skills-list-runtime.ts +3 -3
- package/src/tool-runtime/self-cognition-runtimes.ts +160 -2
- package/src/user-config.ts +84 -4
- package/src/version/packed-revision.json +1 -1
- package/src/video-pipeline/recipes/character.ts +42 -5
- package/src/video-pipeline/recipes/explainer.ts +213 -0
- package/src/video-pipeline/recipes/ffmpeg.ts +1 -1
- package/src/video-pipeline/recipes/free-line.ts +16 -3
- package/src/video-pipeline/recipes/remote.ts +7 -1
- package/src/video-pipeline/vflow-reference.ts +109 -0
- package/src/video-pipeline/walk-line.ts +3 -2
- package/apps/pwa/out/_next/static/chunks/06ef2eba-d1b99ac6d2ea663c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/1413-7179a49332db5a80.js +0 -1
- package/apps/pwa/out/_next/static/chunks/150-25c277137a9638ab.js +0 -1
- package/apps/pwa/out/_next/static/chunks/1640-bc99ec23bbe29d12.js +0 -1
- package/apps/pwa/out/_next/static/chunks/2300-de059d0ab2a16630.js +0 -1
- package/apps/pwa/out/_next/static/chunks/2684-05b0a81e75298c16.js +0 -1
- package/apps/pwa/out/_next/static/chunks/3026-2ed7e317d9b02102.js +0 -25
- package/apps/pwa/out/_next/static/chunks/3611-77496ada38cd6358.js +0 -1
- package/apps/pwa/out/_next/static/chunks/3717-68957c4dc9ceec90.js +0 -1
- package/apps/pwa/out/_next/static/chunks/3929-46ba9af6f9d895cc.js +0 -1
- package/apps/pwa/out/_next/static/chunks/4015-62c5a3744698a8da.js +0 -1
- package/apps/pwa/out/_next/static/chunks/4217-3c4317a7905f9eeb.js +0 -1
- package/apps/pwa/out/_next/static/chunks/4240-3230f230fae2d7d5.js +0 -1
- package/apps/pwa/out/_next/static/chunks/4271-e53e075ebd48593b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/4377.3b125fef79576dfd.js +0 -5
- package/apps/pwa/out/_next/static/chunks/4853-35fdbe6bdbcdc0fc.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5007-6a3b30e23ff68a6e.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5244-12b424c42070f5ff.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5611-df00af3e4e4a61bb.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5890-dca2321773be829a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/6123-37c9dc57ddaa7ab0.js +0 -1
- package/apps/pwa/out/_next/static/chunks/6588-b7975304a302ab91.js +0 -1
- package/apps/pwa/out/_next/static/chunks/6604-c03961962b6273b6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7109-cf8c87e968e83a65.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7178-30f77877525a2efb.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7262-871563fc9663730b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7294-f0eeebb92aa93459.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7429-881af44deed6958f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7527.943753b6ed829f09.js +0 -1
- package/apps/pwa/out/_next/static/chunks/764-a01e858525a190c0.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7868-e35d5f7985ecd3f1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8320-51484af7b01fdfa5.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9131-52f5fad51cceca04.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9238.2cd5f47f4bf7a2c3.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/_not-found/page-70c404c7bb6d7f0f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-3ffd80166b1050aa.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/page-4bcc63e272ae8205.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/chat/page-880734bd07d62882.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/control/page-72bd384898d88955.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-1018bbc9586ecae5.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/page-de86f8c38076dd7b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/healthz/route-70c404c7bb6d7f0f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/intake/page-cd6447196d8bafe6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-e733630c47fa102b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-d1c1a8357b161875.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/market/page-60c514128e81d787.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-8b637b7e325e13c1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-63301d11dbe73836.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-a8cf0dc472060db2.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/page-02fba54881ccfa49.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/reflection/page-d5e8e90f0038f8de.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-b1ef6824f494466a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/sessions/page-d589bb0f233b1009.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-c9bbaebef4cda059.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/page-3639ce7916b92fed.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-7450ae7478955653.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/layout-70c404c7bb6d7f0f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/page-b41ca4abfd618452.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/share/page-b333dd79ebee3fa9.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-8be1227492793655.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-6e03e9371b552e0d.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/term/page-911411e9fca18d03.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/page-792170e649b37088.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-c40463466937bc91.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-509c798b80384536.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-1cac2aa21d77c417.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workspace/page-9626799d9b4a2cd1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-84cd419494e16b0f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/framework-361487d2da603917.js +0 -1
- package/apps/pwa/out/_next/static/chunks/main-app-607f5beb94cb2e07.js +0 -1
- package/apps/pwa/out/_next/static/chunks/main-d0222d219e29dddf.js +0 -1
- package/apps/pwa/out/_next/static/chunks/pages/_app-a26980a31126decc.js +0 -1
- package/apps/pwa/out/_next/static/chunks/pages/_error-edb2b37decf23e4d.js +0 -1
- package/apps/pwa/out/_next/static/css/eaa74e8bb2f62269.css +0 -1
- package/apps/pwa/out/_next/static/ktMqfrBbf8BMGac0jqLrb/_buildManifest.js +0 -1
- /package/apps/pwa/out/_next/static/{ktMqfrBbf8BMGac0jqLrb → keajW7DDyghEs1y8oJe6T}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { debug } from '../../debug/log.js';
|
|
5
|
+
import { sendOutbound } from '../../domains/outbound-alert.js';
|
|
6
|
+
import { effectiveInstanceRoot } from '../../instance/resolve.js';
|
|
7
|
+
import { jsonResponse } from './json-response.js';
|
|
8
|
+
|
|
9
|
+
export const CONSULT_REQUESTS_PATH = '/v1/consult-requests';
|
|
10
|
+
|
|
11
|
+
interface ConsultRequest {
|
|
12
|
+
receiptId: string;
|
|
13
|
+
name: string;
|
|
14
|
+
org: string | null;
|
|
15
|
+
kind: 'company' | 'personal';
|
|
16
|
+
interest: 'A' | 'B';
|
|
17
|
+
contact: string;
|
|
18
|
+
consent: true;
|
|
19
|
+
receivedAt: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ConsultRequestsDeps {
|
|
23
|
+
root?: () => string;
|
|
24
|
+
now?: () => string;
|
|
25
|
+
send?: typeof sendOutbound;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function file(root: string): string {
|
|
29
|
+
return join(root, 'consult-requests', 'requests.jsonl');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function append(path: string, entry: ConsultRequest): void {
|
|
33
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
34
|
+
const previous = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
35
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
36
|
+
try {
|
|
37
|
+
writeFileSync(temp, `${previous}${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
38
|
+
const fd = openSync(temp, 'r');
|
|
39
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
40
|
+
renameSync(temp, path);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
try { unlinkSync(temp); } catch { /* no temp file to remove */ }
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function records(path: string): ConsultRequest[] {
|
|
48
|
+
try {
|
|
49
|
+
return readFileSync(path, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line) as ConsultRequest);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function rejected(field: string): Response {
|
|
57
|
+
debug.log('consult.request', 'rejected', { field });
|
|
58
|
+
return jsonResponse({ error: 'bad_request', field }, 400);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function singleLine(value: string): string {
|
|
62
|
+
return value.trim().replace(/[\r\n\u2028\u2029]+/g, ' ');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function handleConsultRequests(req: Request, deps: ConsultRequestsDeps = {}): Promise<Response> {
|
|
66
|
+
if (req.method !== 'GET' && req.method !== 'POST') return jsonResponse({ error: 'method-not-allowed' }, 405);
|
|
67
|
+
const path = file((deps.root ?? effectiveInstanceRoot)());
|
|
68
|
+
if (req.method === 'GET') {
|
|
69
|
+
const rawLimit = new URL(req.url).searchParams.get('limit');
|
|
70
|
+
const limit = rawLimit === null ? 20 : Number(rawLimit);
|
|
71
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) return rejected('limit');
|
|
72
|
+
try {
|
|
73
|
+
const items = records(path).reverse().slice(0, limit).map(({ receiptId, name, org, kind, interest, receivedAt }) =>
|
|
74
|
+
({ receiptId, name, org, kind, interest, receivedAt }));
|
|
75
|
+
return jsonResponse({ items });
|
|
76
|
+
} catch {
|
|
77
|
+
return jsonResponse({ error: 'journal-unavailable' }, 503);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let body: unknown;
|
|
82
|
+
try { body = await req.json(); }
|
|
83
|
+
catch { return rejected('body'); }
|
|
84
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) return rejected('body');
|
|
85
|
+
const input = body as Record<string, unknown>;
|
|
86
|
+
for (const field of ['name', 'contact'] as const) {
|
|
87
|
+
if (typeof input[field] !== 'string' || !input[field].trim()) return rejected(field);
|
|
88
|
+
}
|
|
89
|
+
if (input.org !== undefined && (typeof input.org !== 'string' || !input.org.trim())) return rejected('org');
|
|
90
|
+
if (input.kind !== 'company' && input.kind !== 'personal') return rejected('kind');
|
|
91
|
+
if (input.interest !== 'A' && input.interest !== 'B') return rejected('interest');
|
|
92
|
+
if (input.consent !== true) return rejected('consent');
|
|
93
|
+
|
|
94
|
+
const entry: ConsultRequest = {
|
|
95
|
+
receiptId: `R-${randomUUID()}`,
|
|
96
|
+
name: singleLine(input.name as string),
|
|
97
|
+
org: typeof input.org === 'string' ? singleLine(input.org) : null,
|
|
98
|
+
kind: input.kind,
|
|
99
|
+
interest: input.interest,
|
|
100
|
+
contact: (input.contact as string).trim(),
|
|
101
|
+
consent: true,
|
|
102
|
+
receivedAt: (deps.now ?? (() => new Date().toISOString()))(),
|
|
103
|
+
};
|
|
104
|
+
try { append(path, entry); }
|
|
105
|
+
catch {
|
|
106
|
+
debug.log('consult.request', 'rejected', { field: 'journal' });
|
|
107
|
+
return jsonResponse({ error: 'journal-unavailable' }, 503);
|
|
108
|
+
}
|
|
109
|
+
debug.log('consult.request', 'accepted', { receiptId: entry.receiptId });
|
|
110
|
+
try {
|
|
111
|
+
(deps.send ?? sendOutbound)(
|
|
112
|
+
`📮 상담 문의 ${entry.receiptId} · ${entry.name}(${entry.kind === 'personal' ? '개인' : (entry.org ?? '회사')}) · 관심 ${entry.interest}\n앱에서 보기`,
|
|
113
|
+
'alert',
|
|
114
|
+
);
|
|
115
|
+
} catch { /* the local receipt remains accepted if delivery is unavailable */ }
|
|
116
|
+
return jsonResponse({ receiptId: entry.receiptId, receivedAt: entry.receivedAt }, 202);
|
|
117
|
+
}
|
|
@@ -66,6 +66,8 @@ export interface NexusDiscordTriggerBotOpts {
|
|
|
66
66
|
* commands (/cc·/fork·/voice-join·…) route into the same composed
|
|
67
67
|
* message pipeline. Absent ⇒ interactions ignored (legacy). */
|
|
68
68
|
onInteraction?: (raw: Record<string, unknown>) => void | Promise<void>;
|
|
69
|
+
/** EV12c — guild text channels answered like DMs (`discord.chatChannels`). Absent ⇒ DM-only. */
|
|
70
|
+
guildTextChannels?: string[];
|
|
69
71
|
/** Optional logger. Defaults to `[discord/trigger]` prefix. */
|
|
70
72
|
log?: (msg: string) => void;
|
|
71
73
|
/** Test seam — pre-built DiscordBot (factory skips construction +
|
|
@@ -156,6 +158,7 @@ export function createNexusDiscordTriggerBot(
|
|
|
156
158
|
const bot = opts.bot ?? new DiscordBot({
|
|
157
159
|
token: opts.token,
|
|
158
160
|
allowedUsers: opts.allowedUsers,
|
|
161
|
+
...(opts.guildTextChannels?.length ? { guildTextChannels: opts.guildTextChannels } : {}),
|
|
159
162
|
// Chat path: the injected self-turn handler (M4b nexus wire) or the
|
|
160
163
|
// legacy trigger-only no-op. Workflows wanting a chat reply can
|
|
161
164
|
// still declare a `chatTrigger` (routes via `/v1/workflows/chat/*`).
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
import { ExecRequestRunner } from '../../exec-requests/runner.js';
|
|
3
|
+
import { jsonResponse } from './json-response.js';
|
|
4
|
+
|
|
5
|
+
const mime: Record<string, string> = {
|
|
6
|
+
'.pdf': 'application/pdf', '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
|
|
7
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp',
|
|
8
|
+
'.md': 'text/markdown; charset=utf-8', '.txt': 'text/plain; charset=utf-8',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export async function handleExecRequests(req: Request, runner: ExecRequestRunner): Promise<Response> {
|
|
12
|
+
const path = new URL(req.url).pathname;
|
|
13
|
+
if (path === '/v1/exec-requests') {
|
|
14
|
+
if (req.method === 'POST') {
|
|
15
|
+
let body: unknown;
|
|
16
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: 'bad_request' }, 400); }
|
|
17
|
+
const text = body && typeof body === 'object' && !Array.isArray(body) ? (body as { text?: unknown }).text : undefined;
|
|
18
|
+
if (typeof text !== 'string' || !text.trim()) return jsonResponse({ error: 'bad_request', reason: 'text required' }, 400);
|
|
19
|
+
const item = runner.submit(text.trim());
|
|
20
|
+
return jsonResponse({ id: item.id, status: 'planning' }, 202);
|
|
21
|
+
}
|
|
22
|
+
if (req.method === 'GET') return jsonResponse({ items: runner.store.list().map(item => {
|
|
23
|
+
const current = runner.get(item.id) ?? item;
|
|
24
|
+
return { id: current.id, text: current.text, createdAt: current.createdAt, status: current.status,
|
|
25
|
+
seats: current.seats.map(({ seat, title, status }) => ({ seat, title, status })), resultCount: current.results.length };
|
|
26
|
+
}) });
|
|
27
|
+
return jsonResponse({ error: 'method-not-allowed' }, 405);
|
|
28
|
+
}
|
|
29
|
+
const file = /^\/v1\/exec-requests\/([^/]+)\/files\/([^/]+)$/.exec(path);
|
|
30
|
+
if (file) {
|
|
31
|
+
if (req.method !== 'GET') return jsonResponse({ error: 'method-not-allowed' }, 405);
|
|
32
|
+
let id: string, name: string;
|
|
33
|
+
try { id = decodeURIComponent(file[1]!); name = decodeURIComponent(file[2]!); }
|
|
34
|
+
catch { return jsonResponse({ error: 'not-found' }, 404); }
|
|
35
|
+
const path = runner.file(id, name);
|
|
36
|
+
if (!path) return jsonResponse({ error: 'not-found' }, 404);
|
|
37
|
+
return new Response(Bun.file(path), { headers: { 'content-type': mime[extname(path).toLowerCase()] ?? 'application/octet-stream', 'x-content-type-options': 'nosniff' } });
|
|
38
|
+
}
|
|
39
|
+
const detail = /^\/v1\/exec-requests\/([^/]+)$/.exec(path);
|
|
40
|
+
if (detail) {
|
|
41
|
+
if (req.method !== 'GET') return jsonResponse({ error: 'method-not-allowed' }, 405);
|
|
42
|
+
let id: string;
|
|
43
|
+
try { id = decodeURIComponent(detail[1]!); } catch { return jsonResponse({ error: 'not-found' }, 404); }
|
|
44
|
+
const item = runner.get(id);
|
|
45
|
+
if (!item) return jsonResponse({ error: 'not-found' }, 404);
|
|
46
|
+
const { seats, ...rest } = item;
|
|
47
|
+
return jsonResponse({ ...rest, seats: seats.map(({ seat, title, status, graphId, runId, reason }) =>
|
|
48
|
+
({ seat, title, status, graphId, runId, ...(reason ? { reason } : {}) })) });
|
|
49
|
+
}
|
|
50
|
+
return jsonResponse({ error: 'not-found' }, 404);
|
|
51
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// 현장 업로드 입구 — POST/GET /v1/field/uploads (owner 전용).
|
|
2
|
+
// 저장 규칙은 src/field/field-media.ts 가 유일한 정본이고 텔레그램 `#현장` 도 같은 함수를 쓴다.
|
|
3
|
+
|
|
4
|
+
import { debug } from '../../debug/log.js';
|
|
5
|
+
import { readFieldReelStatus, scheduleFieldReel, fieldReelFile, hasFieldReelFile, type FieldReelOptions } from '../../field/field-reel.js';
|
|
6
|
+
import { getElanousConfigDir } from '../../elanous-config-dir.js';
|
|
7
|
+
import {
|
|
8
|
+
FIELD_MAX_FILE_BYTES,
|
|
9
|
+
FIELD_MAX_REQUEST_BYTES,
|
|
10
|
+
FieldUploadError,
|
|
11
|
+
defaultFieldEvent,
|
|
12
|
+
fieldEventDir,
|
|
13
|
+
isFieldSlug,
|
|
14
|
+
listFieldMedia,
|
|
15
|
+
saveFieldMedia,
|
|
16
|
+
type FieldMediaInput,
|
|
17
|
+
} from '../../field/field-media.js';
|
|
18
|
+
import { jsonResponse } from './json-response.js';
|
|
19
|
+
|
|
20
|
+
export const FIELD_UPLOADS_PATH = '/v1/field/uploads';
|
|
21
|
+
export const FIELD_REEL_PATH = '/v1/field/reel';
|
|
22
|
+
export const FIELD_REEL_FILE_PATH = '/v1/field/reel/file';
|
|
23
|
+
const reelUrl = (event: string) => `${FIELD_REEL_FILE_PATH}?event=${encodeURIComponent(event)}`;
|
|
24
|
+
|
|
25
|
+
/** 멀티파트 경계·헤더 여유. */
|
|
26
|
+
const MULTIPART_SLACK_BYTES = 4 * 1024 * 1024;
|
|
27
|
+
/** JSON 본문은 base64(×4/3) 라 더 크다. */
|
|
28
|
+
const JSON_MAX_BODY_BYTES = Math.ceil((FIELD_MAX_REQUEST_BYTES * 4) / 3) + MULTIPART_SLACK_BYTES;
|
|
29
|
+
/** Bun.serve 의 `maxRequestBodySize`(기본 128 MB) — 이 입구의 JSON 상한까지 받도록 서버가 이 값을 쓴다. */
|
|
30
|
+
export const FIELD_SERVER_MAX_BODY_BYTES = JSON_MAX_BODY_BYTES;
|
|
31
|
+
|
|
32
|
+
interface FieldUploadsDeps {
|
|
33
|
+
authorize?: (request: Request) => boolean;
|
|
34
|
+
/** config 뿌리 — 기본 `getElanousConfigDir()`(데몬의 다른 저장소와 같은 해석). 시험은 temp 를 준다. */
|
|
35
|
+
rootDir?: () => string;
|
|
36
|
+
now?: () => Date;
|
|
37
|
+
reel?: FieldReelOptions;
|
|
38
|
+
/** 설정 `telegram.fieldDefaultEvent` — 앱이 «오늘» 행사 칸의 기본값으로 받는다(비면 field-<날짜>). */
|
|
39
|
+
configuredEvent?: () => string | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function errorResponse(error: FieldUploadError): Response {
|
|
43
|
+
return jsonResponse({ ok: false, error: error.code, reason: error.message }, error.status);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseCapturedAtList(values: FormDataEntryValue[]): Array<string | undefined> {
|
|
47
|
+
const strings = values.filter((v): v is string => typeof v === 'string');
|
|
48
|
+
if (strings.length === 1 && strings[0]!.trim().startsWith('[')) {
|
|
49
|
+
try {
|
|
50
|
+
const arr = JSON.parse(strings[0]!) as unknown;
|
|
51
|
+
if (Array.isArray(arr)) return arr.map((v) => (typeof v === 'string' || typeof v === 'number' ? String(v) : undefined));
|
|
52
|
+
} catch { /* 배열이 아니면 단일 값으로 */ }
|
|
53
|
+
}
|
|
54
|
+
return strings;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readMultipart(req: Request): Promise<FieldMediaInput[]> {
|
|
58
|
+
let form: FormData;
|
|
59
|
+
try { form = await req.formData(); }
|
|
60
|
+
catch { throw new FieldUploadError('bad-request', 'invalid multipart body'); }
|
|
61
|
+
const files = form.getAll('file').filter((v): v is File => typeof v !== 'string');
|
|
62
|
+
const captured = parseCapturedAtList(form.getAll('capturedAt'));
|
|
63
|
+
const captions = form.getAll('caption').filter((v): v is string => typeof v === 'string');
|
|
64
|
+
let total = 0;
|
|
65
|
+
const inputs: FieldMediaInput[] = [];
|
|
66
|
+
for (const [i, file] of files.entries()) {
|
|
67
|
+
if (file.size > FIELD_MAX_FILE_BYTES) throw new FieldUploadError('too-large', 'a file exceeds 100 MB');
|
|
68
|
+
total += file.size;
|
|
69
|
+
if (total > FIELD_MAX_REQUEST_BYTES) throw new FieldUploadError('too-large', 'the request exceeds 250 MB');
|
|
70
|
+
inputs.push({
|
|
71
|
+
originalName: file.name || 'media',
|
|
72
|
+
mimeType: file.type,
|
|
73
|
+
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
74
|
+
...(captured[i] ? { capturedAt: captured[i] } : {}),
|
|
75
|
+
...((captions.length === 1 ? i === 0 : i < captions.length) ? { caption: captions[i] } : {}),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return inputs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function readJson(req: Request): Promise<FieldMediaInput[]> {
|
|
82
|
+
let body: unknown;
|
|
83
|
+
try { body = await req.json(); }
|
|
84
|
+
catch { throw new FieldUploadError('bad-request', 'JSON body required'); }
|
|
85
|
+
const files = body && typeof body === 'object' && !Array.isArray(body) ? (body as { files?: unknown }).files : undefined;
|
|
86
|
+
if (!Array.isArray(files)) throw new FieldUploadError('bad-request', 'files[] required');
|
|
87
|
+
return files.map((raw) => {
|
|
88
|
+
const f = (raw ?? {}) as Record<string, unknown>;
|
|
89
|
+
if (typeof f.name !== 'string' || typeof f.dataBase64 !== 'string') {
|
|
90
|
+
throw new FieldUploadError('bad-request', 'each file needs name and dataBase64');
|
|
91
|
+
}
|
|
92
|
+
// base64 길이로 먼저 거른다 — 파일 상한(FIELD_MAX_FILE_BYTES)을 넘는 것을 디코드하지 않는다.
|
|
93
|
+
if (Math.floor((f.dataBase64.length * 3) / 4) > FIELD_MAX_FILE_BYTES + 3) {
|
|
94
|
+
throw new FieldUploadError('too-large', 'a file exceeds 100 MB');
|
|
95
|
+
}
|
|
96
|
+
const capturedAt = typeof f.capturedAt === 'string' || typeof f.capturedAt === 'number' ? f.capturedAt : undefined;
|
|
97
|
+
return {
|
|
98
|
+
originalName: f.name,
|
|
99
|
+
...(typeof f.mimeType === 'string' ? { mimeType: f.mimeType } : {}),
|
|
100
|
+
bytes: new Uint8Array(Buffer.from(f.dataBase64, 'base64')),
|
|
101
|
+
...(capturedAt !== undefined ? { capturedAt } : {}),
|
|
102
|
+
...(typeof f.caption === 'string' ? { caption: f.caption } : {}),
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function handleFieldUploads(req: Request, deps: FieldUploadsDeps = {}): Promise<Response> {
|
|
108
|
+
const url = new URL(req.url);
|
|
109
|
+
if (![FIELD_UPLOADS_PATH, FIELD_REEL_PATH, FIELD_REEL_FILE_PATH].includes(url.pathname)) return jsonResponse({ error: 'not-found' }, 404);
|
|
110
|
+
if (!deps.authorize?.(req)) return jsonResponse({ error: 'unauthorized' }, 401);
|
|
111
|
+
const rootDir = (deps.rootDir ?? getElanousConfigDir)();
|
|
112
|
+
const event = url.searchParams.get('event') ?? '';
|
|
113
|
+
// event 없는 GET = 기본 행사 이름만(앱 «오늘» 행사 칸 · 텔레그램 `#현장` 과 같은 기본).
|
|
114
|
+
if (req.method === 'GET' && !url.searchParams.has('event')) {
|
|
115
|
+
const defaultEvent = defaultFieldEvent((deps.configuredEvent ?? (() => undefined))(), (deps.now ?? (() => new Date()))());
|
|
116
|
+
return jsonResponse({ defaultEvent });
|
|
117
|
+
}
|
|
118
|
+
if (!isFieldSlug(event)) return errorResponse(new FieldUploadError('bad-slug', 'event slug must match ^[a-z0-9][a-z0-9._-]{0,63}$'));
|
|
119
|
+
|
|
120
|
+
const dir = fieldEventDir(rootDir, event);
|
|
121
|
+
if (url.pathname !== FIELD_UPLOADS_PATH && req.method !== 'GET') return jsonResponse({ error: 'method-not-allowed' }, 405);
|
|
122
|
+
if (url.pathname === FIELD_REEL_PATH) {
|
|
123
|
+
const status = readFieldReelStatus(dir);
|
|
124
|
+
if (!status) return jsonResponse({ error: 'not-found' }, 404);
|
|
125
|
+
return jsonResponse({ event, state: status.state, items: status.items,
|
|
126
|
+
...(status.seconds !== undefined ? { seconds: status.seconds } : {}),
|
|
127
|
+
...(status.finishedAt ? { finishedAt: status.finishedAt } : {}),
|
|
128
|
+
...(status.state === 'failed' && status.error ? { error: status.error } : {}),
|
|
129
|
+
...(status.state === 'done' && hasFieldReelFile(fieldReelFile(dir)) ? { url: reelUrl(event) } : {}) });
|
|
130
|
+
}
|
|
131
|
+
if (url.pathname === FIELD_REEL_FILE_PATH) {
|
|
132
|
+
const status = readFieldReelStatus(dir);
|
|
133
|
+
const file = fieldReelFile(dir);
|
|
134
|
+
if (status?.state !== 'done' || !hasFieldReelFile(file)) return jsonResponse({ error: 'not-found' }, 404);
|
|
135
|
+
return new Response(Bun.file(file), { headers: { 'content-type': 'video/mp4' } });
|
|
136
|
+
}
|
|
137
|
+
if (req.method === 'GET') {
|
|
138
|
+
const files = listFieldMedia(dir);
|
|
139
|
+
const status = readFieldReelStatus(dir);
|
|
140
|
+
debug.log('field.upload', 'listed', { count: files.length });
|
|
141
|
+
return jsonResponse({ event, count: files.length, files,
|
|
142
|
+
...(status ? { reel: { status: status.state, updatedAt: status.updatedAt,
|
|
143
|
+
...(status.state === 'done' && hasFieldReelFile(fieldReelFile(dir)) ? { url: reelUrl(event) } : {}) } } : {}) });
|
|
144
|
+
}
|
|
145
|
+
if (req.method !== 'POST') return jsonResponse({ error: 'method-not-allowed' }, 405);
|
|
146
|
+
|
|
147
|
+
const device = url.searchParams.get('device') ?? '';
|
|
148
|
+
if (!isFieldSlug(device)) return errorResponse(new FieldUploadError('bad-slug', 'device slug must match ^[a-z0-9][a-z0-9._-]{0,63}$'));
|
|
149
|
+
|
|
150
|
+
const contentType = (req.headers.get('content-type') ?? '').toLowerCase();
|
|
151
|
+
const isMultipart = contentType.startsWith('multipart/form-data');
|
|
152
|
+
const isJson = contentType.startsWith('application/json');
|
|
153
|
+
if (!isMultipart && !isJson) {
|
|
154
|
+
return errorResponse(new FieldUploadError('unsupported-media-type', 'use multipart/form-data or application/json'));
|
|
155
|
+
}
|
|
156
|
+
const declared = Number(req.headers.get('content-length') ?? '');
|
|
157
|
+
const bodyCap = isJson ? JSON_MAX_BODY_BYTES : FIELD_MAX_REQUEST_BYTES + MULTIPART_SLACK_BYTES;
|
|
158
|
+
if (Number.isFinite(declared) && declared > bodyCap) {
|
|
159
|
+
debug.log('field.upload', 'refused', { reason: 'too-large', declaredBytes: declared });
|
|
160
|
+
return errorResponse(new FieldUploadError('too-large', 'the request exceeds 250 MB'));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const inputs = isMultipart ? await readMultipart(req) : await readJson(req);
|
|
165
|
+
const caption = url.searchParams.get('caption') ?? undefined;
|
|
166
|
+
const result = saveFieldMedia(inputs, { rootDir, event, device, ...(caption ? { caption } : {}), ...(deps.now ? { now: deps.now } : {}) });
|
|
167
|
+
debug.log('field.upload', 'saved', {
|
|
168
|
+
surface: 'http', files: result.saved.length, bytes: result.saved.reduce((n, s) => n + s.bytes, 0), count: result.count,
|
|
169
|
+
});
|
|
170
|
+
scheduleFieldReel(result.dir, deps.reel);
|
|
171
|
+
return jsonResponse({ ok: true, event, saved: result.saved, count: result.count });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error instanceof FieldUploadError) {
|
|
174
|
+
debug.log('field.upload', 'refused', { surface: 'http', reason: error.code });
|
|
175
|
+
return errorResponse(error);
|
|
176
|
+
}
|
|
177
|
+
debug.log('field.upload', 'failed', { surface: 'http' });
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// EV10d — «게시 대기» 피드 초안: 승인 노드가 `{"kind":"feed-preview"}` 이면 그 런의 현장폴더 초안
|
|
2
|
+
// (`<folder>/feed/feed-draft.json`)이 정본이다(계약 = MK 10-01 · field-feed 그래프). 경로는 «런 상태의
|
|
3
|
+
// input.folder» 에서만 구하고, 그 폴더는 `<configDir>/field/<slug>` 꼴일 때만 받는다(realpath 로 탈출 거부).
|
|
4
|
+
// 사람의 «수정 저장»은 허용된 칸만 고쳐 그 파일을 덮는다 — 승인 상태는 바꾸지 않는다.
|
|
5
|
+
import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { basename, dirname, join, normalize, sep } from 'node:path';
|
|
7
|
+
import { fieldEventDir, isFieldSlug } from '../../field/field-media.js';
|
|
8
|
+
import type { GraphRunState } from '../../graph-runner/runner.js';
|
|
9
|
+
|
|
10
|
+
export interface FeedSlide { image: string; caption: string; include: boolean; [key: string]: unknown }
|
|
11
|
+
export interface FeedDraft {
|
|
12
|
+
kind: 'feed-draft';
|
|
13
|
+
version: 1;
|
|
14
|
+
revision: number;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
updatedBy: string;
|
|
17
|
+
slides: FeedSlide[];
|
|
18
|
+
caption: { hook: string; body: string };
|
|
19
|
+
hashtags: string[];
|
|
20
|
+
cover: { text: string; sub: string; image?: string; [key: string]: unknown };
|
|
21
|
+
location: string | null;
|
|
22
|
+
reel: string | null;
|
|
23
|
+
brand?: { name?: string; handle?: string; avatar?: string | null };
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Fields a person may change from the PWA — everything else (rendered*, source, folder…) is preserved. */
|
|
28
|
+
export interface FeedEdit {
|
|
29
|
+
slides: Array<{ image: string; caption?: string; include?: boolean }>;
|
|
30
|
+
caption: { hook: string; body: string };
|
|
31
|
+
hashtags: string[];
|
|
32
|
+
cover: { text: string; sub: string };
|
|
33
|
+
location: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const FEED_DRAFT_FILE = join('feed', 'feed-draft.json');
|
|
37
|
+
|
|
38
|
+
export function isFeedPreviewMessage(message: string): boolean {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(message) as unknown;
|
|
41
|
+
return !!parsed && typeof parsed === 'object' && (parsed as { kind?: unknown }).kind === 'feed-preview';
|
|
42
|
+
} catch { return false; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function real(path: string): string | null {
|
|
46
|
+
try { return realpathSync(path); } catch { return null; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The run's field folder when it is exactly `<configDir>/field/<slug>` (after resolving links), else null. */
|
|
50
|
+
export function feedFolderFor(state: Pick<GraphRunState, 'input'>, configDir: string): string | null {
|
|
51
|
+
const input = state.input as { folder?: unknown } | undefined;
|
|
52
|
+
const folder = typeof input?.folder === 'string' ? input.folder : null;
|
|
53
|
+
if (!folder) return null;
|
|
54
|
+
const slug = basename(folder);
|
|
55
|
+
if (!isFieldSlug(slug)) return null;
|
|
56
|
+
const resolved = real(folder);
|
|
57
|
+
const expected = real(fieldEventDir(configDir, slug));
|
|
58
|
+
if (!resolved || !expected || resolved !== expected) return null;
|
|
59
|
+
if (dirname(resolved) !== real(join(configDir, 'field'))) return null;
|
|
60
|
+
return resolved;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function str(value: unknown): string { return typeof value === 'string' ? value : ''; }
|
|
64
|
+
|
|
65
|
+
export function readFeedDraft(folder: string): FeedDraft | null {
|
|
66
|
+
try {
|
|
67
|
+
const raw = JSON.parse(readFileSync(join(folder, FEED_DRAFT_FILE), 'utf8')) as Partial<FeedDraft>;
|
|
68
|
+
if (raw.kind !== 'feed-draft' || raw.version !== 1 || !Array.isArray(raw.slides)) return null;
|
|
69
|
+
return raw as FeedDraft;
|
|
70
|
+
} catch { return null; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type FeedEditResult = { ok: true; draft: FeedDraft } | { ok: false; error: string };
|
|
74
|
+
|
|
75
|
+
/** Apply a person's edit to the current draft. Slides must be the same set of images (order may change,
|
|
76
|
+
* removal is `include:false`), so no new path can enter the draft. */
|
|
77
|
+
export function applyFeedEdit(current: FeedDraft, edit: unknown, now = new Date()): FeedEditResult {
|
|
78
|
+
if (!edit || typeof edit !== 'object') return { ok: false, error: 'bad_request' };
|
|
79
|
+
const e = edit as Partial<FeedEdit>;
|
|
80
|
+
if (!Array.isArray(e.slides) || !e.caption || typeof e.caption !== 'object' || !Array.isArray(e.hashtags)
|
|
81
|
+
|| !e.cover || typeof e.cover !== 'object') return { ok: false, error: 'bad_request' };
|
|
82
|
+
const original = new Map(current.slides.map((slide) => [slide.image, slide]));
|
|
83
|
+
const seen = new Set<string>();
|
|
84
|
+
const slides: FeedSlide[] = [];
|
|
85
|
+
for (const raw of e.slides) {
|
|
86
|
+
const image = str((raw as { image?: unknown })?.image);
|
|
87
|
+
const base = original.get(image);
|
|
88
|
+
if (!base || seen.has(image)) return { ok: false, error: 'unknown-image' };
|
|
89
|
+
seen.add(image);
|
|
90
|
+
const caption = (raw as { caption?: unknown }).caption;
|
|
91
|
+
const include = (raw as { include?: unknown }).include;
|
|
92
|
+
slides.push({ ...base, ...(typeof caption === 'string' ? { caption } : {}), ...(typeof include === 'boolean' ? { include } : {}) });
|
|
93
|
+
}
|
|
94
|
+
if (seen.size !== original.size) return { ok: false, error: 'missing-image' };
|
|
95
|
+
const location = e.location === null ? null : typeof e.location === 'string' ? e.location : current.location;
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
draft: {
|
|
99
|
+
...current,
|
|
100
|
+
slides,
|
|
101
|
+
caption: { ...current.caption, hook: str(e.caption.hook), body: str(e.caption.body) },
|
|
102
|
+
hashtags: e.hashtags.filter((tag): tag is string => typeof tag === 'string').map((tag) => tag.trim()).filter(Boolean),
|
|
103
|
+
cover: { ...current.cover, text: str(e.cover.text), sub: str(e.cover.sub) },
|
|
104
|
+
location,
|
|
105
|
+
revision: (Number.isFinite(current.revision) ? current.revision : 0) + 1,
|
|
106
|
+
updatedBy: 'human',
|
|
107
|
+
updatedAt: now.toISOString(),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function writeFeedDraft(folder: string, draft: FeedDraft): void {
|
|
113
|
+
const target = join(folder, FEED_DRAFT_FILE);
|
|
114
|
+
const temp = `${target}.tmp-${process.pid}`;
|
|
115
|
+
writeFileSync(temp, `${JSON.stringify(draft, null, 2)}\n`);
|
|
116
|
+
renameSync(temp, target);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const MEDIA_RE = /^(?:feed\/[^/\\]+\.(?:png|jpe?g|webp)|reel\/reel-9x16\.mp4)$/i;
|
|
120
|
+
|
|
121
|
+
/** Absolute file for a folder-relative media path, only `feed/*.png|jpg|jpeg|webp` and `reel/reel-9x16.mp4`. */
|
|
122
|
+
export function resolveFeedMedia(folder: string, relative: string): string | null {
|
|
123
|
+
if (!relative || relative.startsWith('/') || relative.includes('\\') || relative.includes('\0')) return null;
|
|
124
|
+
const clean = normalize(relative).split(sep).join('/');
|
|
125
|
+
if (clean !== relative || !MEDIA_RE.test(clean)) return null;
|
|
126
|
+
const file = join(folder, clean);
|
|
127
|
+
if (!existsSync(file)) return null;
|
|
128
|
+
const resolved = real(file);
|
|
129
|
+
const root = real(folder);
|
|
130
|
+
if (!resolved || !root || !resolved.startsWith(`${root}${sep}`)) return null;
|
|
131
|
+
return resolved;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function mediaContentType(path: string): string {
|
|
135
|
+
const lower = path.toLowerCase();
|
|
136
|
+
if (lower.endsWith('.png')) return 'image/png';
|
|
137
|
+
if (lower.endsWith('.webp')) return 'image/webp';
|
|
138
|
+
if (lower.endsWith('.mp4')) return 'video/mp4';
|
|
139
|
+
return 'image/jpeg';
|
|
140
|
+
}
|