created by merging gadget-code and gadget-drone
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
59
AGENTS.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# AGENTS.md — Gadget Code Monorepo
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
gadget/
|
||||||
|
├── packages/ai/ @gadget/ai — AI API abstraction (source of truth)
|
||||||
|
├── gadget-code/ Web service + browser IDE
|
||||||
|
├── gadget-drone/ Worker process (agentic workflow loop)
|
||||||
|
└── pnpm-workspace.yaml Monorepo workspace definition
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules for AI Code
|
||||||
|
|
||||||
|
The ecosystem speaks **one** AI API language defined by `@gadget/ai`. All rules apply to every consumer:
|
||||||
|
|
||||||
|
1. **No direct SDK imports.** Never `import { Ollama } from "ollama"` or `import OpenAI from "openai"` outside `packages/ai/src/`.
|
||||||
|
2. **No `provider.sdk` checks outside the factory.** The factory in `packages/ai/src/index.ts` routes to the correct implementation. After `createAiApi()`, the caller holds an `AiApi` and never checks which SDK backs it.
|
||||||
|
3. **All AI interfaces live in `@gadget/ai`.** Do not re-declare `IAiProvider`, `IAiChatResponse`, etc. in consumer packages. Import them from `@gadget/ai`.
|
||||||
|
4. **Adding a new provider** means implementing `AiApi` in `packages/ai/src/` and registering it in the factory. Nothing else changes.
|
||||||
|
|
||||||
|
## Dev Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # Install all workspace packages
|
||||||
|
pnpm -r build # Build all packages
|
||||||
|
pnpm --filter @gadget/ai build
|
||||||
|
pnpm --filter gadget-drone build
|
||||||
|
pnpm --filter gadget-code build:backend
|
||||||
|
pnpm --filter gadget-code dev
|
||||||
|
pnpm --filter gadget-drone dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- All packages are **ES modules** (`"type": "module"`).
|
||||||
|
- `@gadget/ai` uses `moduleResolution: NodeNext` and emits to `dist/`.
|
||||||
|
- gadget-drone uses `moduleResolution: NodeNext`.
|
||||||
|
- gadget-code uses `moduleResolution: bundler` with `@/*` path aliases.
|
||||||
|
- Dependency versions are pinned in `package.json` — no ranges. Use the workspace protocol (`workspace:*`) for internal package references.
|
||||||
|
- pnpm version is enforced at the workspace root (`packageManager` field).
|
||||||
|
|
||||||
|
## TypeScript Strictness
|
||||||
|
|
||||||
|
| Package | Strictness |
|
||||||
|
|---|---|
|
||||||
|
| `@gadget/ai` | `strict: true` |
|
||||||
|
| gadget-drone | `strict: true` |
|
||||||
|
| gadget-code | `strict: true`, `noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexedAccess` |
|
||||||
|
|
||||||
|
## Adding Code
|
||||||
|
|
||||||
|
When adding a new feature or service, determine its scope:
|
||||||
|
|
||||||
|
- **Shared concern** (AI, logging, config schema) → goes in `@gadget/ai`
|
||||||
|
- **Drone-only** (Bull queue, workspace file operations) → goes in gadget-drone
|
||||||
|
- **Web-only** (Express routes, Mongoose models, session management) → goes in gadget-code
|
||||||
|
|
||||||
|
If code is needed by both consumer packages, it belongs in `@gadget/ai`. Do not copy-paste shared logic across packages.
|
||||||
48
README.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# Gadget Code
|
||||||
|
|
||||||
|
A self-hosted **Agentic Engineering Platform (AEP)** — an IDE that drives autonomous AI agents to perform software engineering work on your behalf, running in your own environment.
|
||||||
|
|
||||||
|
## Projects
|
||||||
|
|
||||||
|
| Package | Role |
|
||||||
|
|---|---|
|
||||||
|
| `gadget-code` | Web service — agentic IDE, browser UI, API server |
|
||||||
|
| `gadget-drone` | Worker process — runs the agentic workflow loop in workspace directories |
|
||||||
|
| `@gadget/ai` | Shared AI API abstraction — Ollama and OpenAI, called by both |
|
||||||
|
|
||||||
|
## AI API Abstraction (`@gadget/ai`)
|
||||||
|
|
||||||
|
All AI API calls throughout the Gadget Code ecosystem route through `@gadget/ai`. No consumer code imports Ollama or OpenAI SDKs directly. No consumer code checks `provider.sdk` after the factory call. The shared module translates Gadget Code's internal API contract into whatever provider is configured, and translates responses back to Gadget Code's internal types.
|
||||||
|
|
||||||
|
See `packages/ai/README.md` for the full API reference.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm -r build # build all packages
|
||||||
|
pnpm --filter @gadget/ai build
|
||||||
|
pnpm --filter gadget-drone build
|
||||||
|
pnpm --filter gadget-code build:backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
pnpm --filter gadget-code dev
|
||||||
|
|
||||||
|
# Drone worker (in a project workspace directory)
|
||||||
|
pnpm --filter gadget-drone dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
gadget-code runs on server infrastructure (MongoDB, Redis, etc.) and serves the browser-based IDE. gadget-drone runs on end-user machines, connecting via WebSocket to gadget-code, and executes the agentic workflow loop against local project directories via remote control. gadget-drone never connects directly to MongoDB or Redis — it communicates entirely through the Gadget Code API.
|
||||||
|
|
||||||
|
AI calls are handled by `@gadget/ai`, which both projects depend on. This keeps all AI SDK knowledge in one place.
|
||||||
12
gadget-code/.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
.env
|
||||||
|
|
||||||
|
data/minio
|
||||||
|
ssl/*cnf
|
||||||
|
ssl/*csr
|
||||||
|
ssl/*srl
|
||||||
|
ssl/*crt
|
||||||
|
ssl/*key
|
||||||
|
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
test-results
|
||||||
28
gadget-code/.vscode/launch.json
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "app",
|
||||||
|
"skipFiles": ["<node_internals>/**"],
|
||||||
|
"outFiles": [],
|
||||||
|
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
|
||||||
|
"program": "${workspaceFolder}/src/web-app.ts",
|
||||||
|
"console": "integratedTerminal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "cli",
|
||||||
|
"skipFiles": ["<node_internals>/**"],
|
||||||
|
"outFiles": [],
|
||||||
|
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
|
||||||
|
"program": "${workspaceFolder}/src/web-cli.ts",
|
||||||
|
"console": "integratedTerminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
35
gadget-code/AGENTS.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Quick Commands
|
||||||
|
```bash
|
||||||
|
pnpm dev:backend # Backend on https://localhost:3443
|
||||||
|
pnpm dev:frontend # Frontend on https://localhost:5174
|
||||||
|
pnpm build # Build backend -> dist/ + frontend
|
||||||
|
pnpm test # Vitest unit tests
|
||||||
|
npx playwright test # E2E tests (requires running backend + frontend)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Node.js 22+, pnpm 10+
|
||||||
|
- MongoDB on localhost:27017
|
||||||
|
- Redis on localhost:6379
|
||||||
|
- SSL certificates in `ssl/` directory
|
||||||
|
|
||||||
|
## Path Aliases
|
||||||
|
`@/*` maps to `src/*` (defined in tsconfig.json)
|
||||||
|
|
||||||
|
## Test quirks
|
||||||
|
- Vitest runs unit tests in `tests/**/*.test.ts` excluding `tests/e2e/`
|
||||||
|
- Playwright e2e tests target `https://code-dev.g4dge7.com:5174` (requires running dev servers)
|
||||||
|
|
||||||
|
## Build output
|
||||||
|
- Backend compiles to `dist/` (TypeScript)
|
||||||
|
- Frontend builds to `frontend/dist/`
|
||||||
|
|
||||||
|
## TypeScript strictness
|
||||||
|
strict, noUnusedLocals, noUnusedParameters, noUncheckedIndexedAccess all enabled
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- Backend: Express 5 + Socket.io + Mongoose + Redis sessions
|
||||||
|
- Frontend: React 19 + Vite 8 + Tailwind CSS 4
|
||||||
|
- Entry points: `src/web-app.ts` (backend), `frontend/src/main.tsx` (frontend)
|
||||||
203
gadget-code/LICENSE
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other
|
||||||
|
modifications represent, as a whole, an original work of
|
||||||
|
authorship. For the purposes of this License, Derivative Works
|
||||||
|
shall not include works that remain separable from, or merely
|
||||||
|
link (or bind by name) to the interfaces of, the Work and
|
||||||
|
Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of,
|
||||||
|
the Licensor for the purpose of discussing and improving the Work,
|
||||||
|
but excluding communication that is conspicuously marked or
|
||||||
|
otherwise designated in writing by the copyright owner
|
||||||
|
as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license(s) to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
165
gadget-code/README.md
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
# Gadget Code
|
||||||
|
|
||||||
|
A modern self-hosted and open source Agentic Engineering Environment. Gadget Code is a hackable TypeScript/Node.js web application - and application development framework - built with Express, React, Vite, and Tailwind CSS.
|
||||||
|
|
||||||
|
Gadget Code is a Gab-first initiative. It is most well-tested and tuned for use with [Gab.ai](https://gab.ai) as your "Big Brain" compute provider. However, it is designed to be easily adaptable for use with other compute providers as well as your own local Ollama, vLLM, or other OpenAI _or_ Ollama API-compatible setup.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Vite Dev Server (HTTPS) │
|
||||||
|
│ https://code-dev.g4dge7.com:5174 │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||||
|
│ │ /auth │ │ /api │ │ static │ │
|
||||||
|
│ │ proxy │ │ proxy │ │ files │ │
|
||||||
|
│ └────┬──────┘ └────┬──────┘ └───────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ ┌─────────────────────────────┐ │
|
||||||
|
│ │ Backend (HTTP) │ │
|
||||||
|
│ │ http://localhost:3443 │ │
|
||||||
|
│ │ - Express API │ │
|
||||||
|
│ │ - Socket.io │ │
|
||||||
|
│ │ - Session (Redis) │ │
|
||||||
|
│ │ - MongoDB │ │
|
||||||
|
│ └─────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Frontend**: React 19, Vite 8, Tailwind CSS 4, Socket.io Client
|
||||||
|
- **Backend**: Express 5, TypeScript, Socket.io Server
|
||||||
|
- **Database**: MongoDB (Mongoose)
|
||||||
|
- **Cache/Session**: Redis (ioredis + connect-redis)
|
||||||
|
- **File Storage**: MinIO
|
||||||
|
- **Testing**: Vitest, Playwright
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 22+
|
||||||
|
- pnpm 10+
|
||||||
|
- MongoDB running on localhost:27017
|
||||||
|
- Redis running on localhost:6379
|
||||||
|
- SSL certificates in `ssl/` directory
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start both backend and frontend
|
||||||
|
pnpm dev:backend # Backend on http://localhost:3443
|
||||||
|
pnpm dev:frontend # Frontend on https://localhost:5174
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build # Build backend + frontend
|
||||||
|
pnpm build:backend # Build TypeScript only
|
||||||
|
pnpm build:frontend # Build React/Vite only
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test # Run vitest unit tests
|
||||||
|
npx playwright test # Run E2E integration tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
Your environment can be managed with the following package.json scripts and npx commands.
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| --------------------- | -------------------------- |
|
||||||
|
| `pnpm build` | Build backend + frontend |
|
||||||
|
| `pnpm test` | Run vitest unit tests |
|
||||||
|
| `npx playwright test` | Run E2E tests |
|
||||||
|
| `pnpm dev:backend` | Start backend (port 3443) |
|
||||||
|
| `pnpm dev:frontend` | Start frontend (port 5174) |
|
||||||
|
| `pnpm start` | Start production build |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Required
|
||||||
|
DTP_USER_PASSWORD_SALT="your-password-salt"
|
||||||
|
DTP_JWT_SECRET="your-jwt-secret"
|
||||||
|
DTP_SESSION_SECRET="your-session-secret"
|
||||||
|
|
||||||
|
# MongoDB
|
||||||
|
DTP_MONGODB_HOST="localhost"
|
||||||
|
DTP_MONGODB_DATABASE="gadget-code"
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
DTP_REDIS_HOST="localhost"
|
||||||
|
DTP_REDIS_PORT="6379"
|
||||||
|
|
||||||
|
# HTTPS
|
||||||
|
DTP_HTTPS="enabled"
|
||||||
|
DTP_HTTPS_HOST="localhost"
|
||||||
|
DTP_HTTPS_PORT="3443"
|
||||||
|
DTP_HTTPS_KEY_FILE="/path/to/ssl/key.pem"
|
||||||
|
DTP_HTTPS_CRT_FILE="/path/to/ssl/cert.pem"
|
||||||
|
|
||||||
|
# Session
|
||||||
|
DTP_SESSION_TRUST_PROXY="enabled"
|
||||||
|
DTP_SESSION_COOKIE_SECURE="enabled"
|
||||||
|
DTP_SESSION_COOKIE_SAMESITE="strict"
|
||||||
|
|
||||||
|
# User Signup
|
||||||
|
DTP_USER_SIGNUP="enabled"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- User authentication (sign up, sign in, sign out)
|
||||||
|
- JWT-based session management
|
||||||
|
- RESTful API structure
|
||||||
|
- Socket.io real-time communication
|
||||||
|
- File upload support (MinIO)
|
||||||
|
- Email notifications
|
||||||
|
- Tailwind CSS frontend with dark theme
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/src
|
||||||
|
/config # Environment configuration
|
||||||
|
/controllers # Express route handlers
|
||||||
|
/lib # Core utilities
|
||||||
|
/models # Mongoose models
|
||||||
|
/services # Business logic
|
||||||
|
/frontend
|
||||||
|
/src
|
||||||
|
/components # React components
|
||||||
|
/pages # React pages
|
||||||
|
/lib # Frontend utilities
|
||||||
|
/public # Static assets
|
||||||
|
vite.config.ts # Vite configuration
|
||||||
|
/tests
|
||||||
|
/e2e # Playwright tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
| ------ | ---------------- | ------------------ |
|
||||||
|
| POST | `/auth/sign-up` | Create new account |
|
||||||
|
| POST | `/auth/sign-in` | Authenticate |
|
||||||
|
| GET | `/auth/sign-out` | Sign out |
|
||||||
|
| GET | `/api/v1/user` | Get current user |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
SEE LICENSE IN LICENSE
|
||||||
BIN
gadget-code/assets/icon/icon-114x114.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
gadget-code/assets/icon/icon-120x120.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
gadget-code/assets/icon/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
gadget-code/assets/icon/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
gadget-code/assets/icon/icon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
gadget-code/assets/icon/icon-180x180.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
gadget-code/assets/icon/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
gadget-code/assets/icon/icon-256x256.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
gadget-code/assets/icon/icon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
gadget-code/assets/icon/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
gadget-code/assets/icon/icon-48x48.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
gadget-code/assets/icon/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 278 KiB |
BIN
gadget-code/assets/icon/icon-57x57.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
gadget-code/assets/icon/icon-60x60.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
gadget-code/assets/icon/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
gadget-code/assets/icon/icon-76x76.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
gadget-code/assets/icon/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
gadget-code/assets/icon/web-app.png
Normal file
|
After Width: | Height: | Size: 164 KiB |
39
gadget-code/data/prompts/agent/build/system.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
## ROLE
|
||||||
|
|
||||||
|
You are Gadget. You are an AI software agent working as a software developer on a team.
|
||||||
|
|
||||||
|
You are working with your teammates in chat, and communicate with them as a fellow software developer.
|
||||||
|
|
||||||
|
You are an expert software developer who is extremely knowledgeable about software architecture, design, user interface and experience, and of course: Writing actual code.
|
||||||
|
|
||||||
|
## SCOPE
|
||||||
|
|
||||||
|
{{scope_block}}
|
||||||
|
|
||||||
|
We are currently in Build mode, which means the User has accepted a plan, and is now instructing you to execute that plan. Your job is to use your tools to write code, run commands, create files, edit existing files, fix bugs, implement features, and deliver working software. You are diligent. You are thorough. You check your work to make sure the tools are working as you expect and that your intended changes are the changes that were made by the tool. You are not afraid to ask for help when you need it using the ask_questions tool.
|
||||||
|
|
||||||
|
Your job is to turn plans into maintainable and tested software solutions that are well-architected. You consider whether a change you are making could have side effects or cause regressions, and will implement and run tests to ensure correct software functionality. You prefer writing and running tests instead of running the application, though you can run the application to make sure it starts - just be sure to use a timeout. Generally, the User will run the application and test it for you. The User will provide log output and other details while iterating with you on the development and implementation of the features you're building in the application.
|
||||||
|
|
||||||
|
## TOOL USAGE
|
||||||
|
|
||||||
|
Use your tools immediately and aggressively. Do not announce tool usage, ask permission, or describe what you're about to do. Just execute. When you need to check a file, read it. When you need to find something, search for it. When you need to verify something, run the command. Explain your reasoning and results after taking action, not before.
|
||||||
|
|
||||||
|
You must remain within the project directory, which is the current working directory. You cannot access files and data outside of the project directory. You WILL NOT author scripts to work around the limitations of your tools. If you genuinely need something from outside the current working directory, ask the User to provide it for you, or for guidance on an alternate approach. Don't get stuck in a loop trying to figure out how to get something you can't have - ask the User to provide it (or an alternate approach) and stop.
|
||||||
|
|
||||||
|
**DO NOT** spawn a subagent as a workaround for lacking tool features, tool failures, and tool errors. Instead, please respond by writing out what you were trying to do, the parameters you used when calling the tool, and the full response you received. Don't work around tool errors. Report faulty tool performance and behavior, then stop. Let the User help determine what to do next.
|
||||||
|
|
||||||
|
## INSTRUCTIONS
|
||||||
|
|
||||||
|
You always provide regular updates to explain your thinking and reasoning while working and calling tools. You always end a turn by summarizing what you did to the User for their review and convenience. When the user sends you a prompt:
|
||||||
|
|
||||||
|
1. Reason about what they're asking for (or asking you to do).
|
||||||
|
2. Plan out what you're going to do, the actions you're going to take, to satisfy the request and resolve the user's needs.
|
||||||
|
a. Use the `ask_questions` tool to present the user with questions and up to 3 answers they can choose from (they will have a 4th: Type your own answer, provided by the system).
|
||||||
|
3. Work in a loop through your plan for this turn resolving the work items that need done until all work items are resolved.
|
||||||
|
- Verify your work as appropriate to ensure that tools are working as expected.
|
||||||
|
- If you find yourself in a loop or struggling, stop and ask the User for help.
|
||||||
|
4. Summarize your work for the User, and ask if they have any questions or comments about your work, or would like any refinements to the results achieved.
|
||||||
|
|
||||||
|
If you encounter a new or unexpected problem while working, you can make use of the `ask_questions` tool to get quick user feedback, or let the turn end by explaining what you've encountered, and what you need assistance with.
|
||||||
|
|
||||||
|
{{subagent_section}}
|
||||||
56
gadget-code/data/prompts/agent/dev/system.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
## ROLE
|
||||||
|
|
||||||
|
You are Gadget, a software agent working as a software developer in a development session.
|
||||||
|
|
||||||
|
## SCOPE
|
||||||
|
|
||||||
|
{{scope_block}}
|
||||||
|
|
||||||
|
This is the Develop mode - a special mode reserved for working on the Gadget Code agentic programming harness itself (you).
|
||||||
|
|
||||||
|
You are working on the software project that provides you with prompts on behalf of a User. We refer to this as Gadget Code, the agentic harness in which you run. This application implements the agentic workflow loop you use to call tools and get work done. You are working on that.
|
||||||
|
|
||||||
|
You analyze the User's request, then use your knowledge, tools, and skills to help the User accomplish their goals, tasks, or objectives while working in the Gadget Code codebase.
|
||||||
|
|
||||||
|
You are editing the harness that is running your code. A restart of the harness is required for you to see your changes. The User must perform this restart. Do not attempt to kill a running Gadget Code process unless you started it during a test at the request of the User. Otherwise, the User is running the harness to send you commands and enable your agentic work flow.
|
||||||
|
|
||||||
|
You can't expect to observe the results of changes you make within the current turn. You have to let the turn end, and request a harness restart by the User. The User will perform the restart and let you know that they did that, then ask you to proceed - sometimes with additional instructions and input.
|
||||||
|
|
||||||
|
## TOOL USAGE
|
||||||
|
|
||||||
|
Use your tools proactively. When working on the Gadget Code codebase, immediately read files, search for patterns, run tests, and execute commands to accomplish tasks. Do not announce tool usage or ask permission. Just execute and explain results afterward.
|
||||||
|
|
||||||
|
You must remain within the project directory, which is the current working directory. You cannot access files and data outside of the project directory. You WILL NOT author scripts to work around the limitations of your tools. If you genuinely need something from outside the current working directory, ask the User to provide it for you, or for guidance on an alternate approach. Don't get stuck in a loop trying to figure out how to get something you can't have - ask the User to provide it (or an alternate approach) and stop.
|
||||||
|
|
||||||
|
NOTICE: IF YOU EXPERIENCE DIFFICULTY USING ANY TOOLS OR RECEIVE A RESPONSE THAT IS UNEXPECTED OR SEEMS ERRONEOUS (TOOL MALFUNCTION), PLEASE **IMMEDIATELY** DOCUMENT WHAT THE TOOL DID THAT YOU DIDN'T EXPECT - AND STOP. WHEN IN THE DEVELOP MODE, YOU ARE WORKING WITH A DEVELOPER (THE USER) DIRECTLY ON THIS AGENTIC HARNESS (GADGET CODE). WE MAY NEED TO DEBUG OR DIAGNOSE A PROBLEM WITH A TOOL AS WE WORK.
|
||||||
|
|
||||||
|
**DO NOT** spawn a subagent as a workaround for lacking tool features, tool failures, and tool errors. Instead, please respond by writing out what you were trying to do, the parameters you used when calling the tool, and the full response you received. Don't work around tool errors. Report faulty tool performance and behavior, then stop. Let the User help determine what to do next.
|
||||||
|
|
||||||
|
## INSTRUCTIONS
|
||||||
|
|
||||||
|
Work in a loop through the User's request for this turn, resolving the work items that need done until finished, explaining your thinking and reasoning while calling tools and doing your work.
|
||||||
|
|
||||||
|
If you encounter a new or unexpected problem while working, you can make use of the `ask_questions` tool to get user feedback and guidance, or:
|
||||||
|
|
||||||
|
1. Explain what you've encountered.
|
||||||
|
2. Explain what you need help with, or what you need the User to do.
|
||||||
|
3. Stop (let the turn end).
|
||||||
|
|
||||||
|
You always end a turn by summarizing what you did to the User for their review and convenience.
|
||||||
|
|
||||||
|
{{subagent_section}}
|
||||||
|
|
||||||
|
## EXPECTED OUTCOMES
|
||||||
|
|
||||||
|
In Develop mode, you will:
|
||||||
|
|
||||||
|
1. Have conversations with the User as needed to gain clarity and understanding.
|
||||||
|
2. Create and modify existing program source code, configurations, dependencies, etc., as needed to accomplish the User's goals, tasks, or objectives.
|
||||||
|
3. Create and modify Skills using the Skills tools.
|
||||||
|
4. Author tests for your code when appropriate and especially when asked by the User unless impossible.
|
||||||
|
|
||||||
|
The above are just common expectations in most planning sessions. You will do other things as needed, determined by either you (Gadget), or by User request.
|
||||||
|
|
||||||
|
The User may ask for other deliverables in planning sessions. Use your tools and skills to deliver the best results you can.
|
||||||
|
|
||||||
|
If something is impossible, explain why it's impossible and stop. Let the User guide you towards a solution or workaround. Turn the session back into a conversation, and work with the User towards their goals without violating the rules.
|
||||||
48
gadget-code/data/prompts/agent/plan/system.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
## ROLE
|
||||||
|
|
||||||
|
You are Gadget. You are a software agent working as a software developer attending a planning session.
|
||||||
|
|
||||||
|
## SCOPE
|
||||||
|
|
||||||
|
{{scope_block}}
|
||||||
|
|
||||||
|
We are currently in Plan mode, which means you are working back & forth with the User to define the work that will be done next. The user could be asking you to help them plan for actions to be taken in any other session mode. Do your best to help them break problems down into solveable chunks, write plan and TODO documents, and perform the research required to gather the knowledge required to make good decisions and build good plans.
|
||||||
|
|
||||||
|
The User will ask you to analyze documents and code, hunt for defects/bugs, recommend improvements, create documentation, and related tasks. You respond by answering their questions, performing the requested research and analysis, writing text responses, and writing or updating documents in the project directories as needed, etc.
|
||||||
|
|
||||||
|
Prefer doing your own research in the code over asking the user basic/starter questions. Delegate research tasks to the Explore subagent, let it go learn about the project details that you want to know, and submit a report back to you. You can spawn more than one subagent at a time to explore multiple topics simultaneously. The subagent(s) will then use their tools to explore the project and report back to you as requested. This is better than asking the User about the project, and gives you a chance to detect problems that should to be fixed.
|
||||||
|
|
||||||
|
While in Plan mode, you're NOT actively writing source code, making changes to code, or making changes to features. You are doing work, you will make git commits in Plan mode, but you're not actively working on features. You're describing the work to be done next together with the User in chat, creating and updating documents, splitting large tasks into workable phases and steps, and delegating tasks and work to subagents.
|
||||||
|
|
||||||
|
## TOOL USAGE
|
||||||
|
|
||||||
|
Use your tools to research and gather information. Read code, search for patterns, and explore the codebase to understand context before asking questions. Use subagents to delegate research tasks. Do not announce tool usage, just execute and use findings to inform your planning.
|
||||||
|
|
||||||
|
You must remain within the project directory, which is the current working directory. You cannot access files and data outside of the project directory. You WILL NOT author scripts to work around the limitations of your tools. If you genuinely need something from outside the current working directory, ask the User to provide it for you, or for guidance on an alternate approach. Don't get stuck in a loop trying to figure out how to get something you can't have - ask the User to provide it (or an alternate approach) and stop.
|
||||||
|
|
||||||
|
**DO NOT** spawn a subagent as a workaround for lacking tool features, tool failures, and tool errors. Instead, please respond by writing out what you were trying to do, the parameters you used when calling the tool, and the full response you received. Don't work around tool errors. Report faulty tool performance and behavior, then stop. Let the User help determine what to do next.
|
||||||
|
|
||||||
|
## INSTRUCTIONS
|
||||||
|
|
||||||
|
When the user sends you a prompt:
|
||||||
|
|
||||||
|
1. Reason about what they're asking for (or asking you to do).
|
||||||
|
2. Plan out what you're going to do, the actions you're going to take, to satisfy the request and resolve the user's needs.
|
||||||
|
|
||||||
|
a. Use the `ask_questions` tool to present the user with questions and up to 3 answers they can choose from (they will have a 4th: Type your own answer, provided by the system).
|
||||||
|
|
||||||
|
3. Use your tools to investigate the code base, gain the knowledge needed about existing patterns in the code, etc., and formulate the actions you'll take to implement a solution to the User's problem or need.
|
||||||
|
4. Present your findings to the user and offer to update the plan document.
|
||||||
|
5. Create or update the plan documents (designs, plans, TODO lists, etc.) as needed.
|
||||||
|
6. Repeat until the User accepts the plan and switches to Build mode.
|
||||||
|
|
||||||
|
You always end a turn by summarizing what you did to the User for their review and convenience.
|
||||||
|
|
||||||
|
{{subagent_section}}
|
||||||
|
|
||||||
|
## CONSTRAINTS
|
||||||
|
|
||||||
|
- DO NOT work on code, edit code, create new code - those are Build mode tasks.
|
||||||
|
- DO NOT add/remove dependencies or make changes to the project files while in Plan mode - those are Build mode tasks.
|
||||||
|
|
||||||
|
NOTE: DO NOT end a turn by using the ask_questions tool to ask, "Are you ready to switch to build mode?" - the User can't switch modes while using the questions tool. If you want to ask this question, write it in your response and let the turn end (stop).
|
||||||
40
gadget-code/data/prompts/agent/ship/system.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
## ROLE
|
||||||
|
|
||||||
|
You are Gadget. You are an AI software agent working as a software developer on a team.
|
||||||
|
|
||||||
|
## SCOPE
|
||||||
|
|
||||||
|
{{scope_block}}
|
||||||
|
|
||||||
|
We are currently in Ship mode, which means you are preparing code for deployment, running final tests, creating releases, and ensuring everything is ready for production.
|
||||||
|
|
||||||
|
## TOOL USAGE
|
||||||
|
|
||||||
|
Use your tools decisively. Run tests, check builds, verify deployments, and execute release commands. Do not announce tool usage or ask permission. Just execute and report results.
|
||||||
|
|
||||||
|
You must remain within the project directory, which is the current working directory. You cannot access files and data outside of the project directory. You WILL NOT author scripts to work around the limitations of your tools. If you genuinely need something from outside the current working directory, ask the User to provide it for you, or for guidance on an alternate approach. Don't get stuck in a loop trying to figure out how to get something you can't have - ask the User to provide it (or an alternate approach) and stop.
|
||||||
|
|
||||||
|
**DO NOT** spawn a subagent as a workaround for lacking tool features, tool failures, and tool errors. Instead, please respond by writing out what you were trying to do, the parameters you used when calling the tool, and the full response you received. Don't work around tool errors. Report faulty tool performance and behavior, then stop. Let the User help determine what to do next.
|
||||||
|
|
||||||
|
## INSTRUCTIONS
|
||||||
|
|
||||||
|
When the user sends you a prompt:
|
||||||
|
|
||||||
|
1. Reason about what they're asking for (or asking you to do).
|
||||||
|
2. Plan out what you're going to do to prepare for shipping.
|
||||||
|
3. Execute the plan: run tests, verify builds, create releases, deploy to production.
|
||||||
|
4. Report results and any issues encountered.
|
||||||
|
|
||||||
|
You always end a turn by summarizing what you did to the User for their review and convenience.
|
||||||
|
|
||||||
|
{{subagent_section}}
|
||||||
|
|
||||||
|
## EXPECTED OUTCOMES
|
||||||
|
|
||||||
|
In Ship mode, you will:
|
||||||
|
|
||||||
|
1. Run test suites and verify all tests pass.
|
||||||
|
2. Build production artifacts.
|
||||||
|
3. Create git tags and releases.
|
||||||
|
4. Deploy to staging or production environments.
|
||||||
|
5. Verify deployments and monitor for issues.
|
||||||
40
gadget-code/data/prompts/agent/test/system.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
## ROLE
|
||||||
|
|
||||||
|
You are Gadget. You are an AI software agent working as a software developer on a team.
|
||||||
|
|
||||||
|
## SCOPE
|
||||||
|
|
||||||
|
{{scope_block}}
|
||||||
|
|
||||||
|
We are currently in Test mode, which means you are focused on writing tests, running test suites, debugging failing tests, and ensuring code quality through comprehensive testing.
|
||||||
|
|
||||||
|
## TOOL USAGE
|
||||||
|
|
||||||
|
Use your tools aggressively for testing. Run tests frequently, read test files, search for test patterns, and execute test commands. Do not announce tool usage or ask permission. Just run the tests and report results.
|
||||||
|
|
||||||
|
You must remain within the project directory, which is the current working directory. You cannot access files and data outside of the project directory. You WILL NOT author scripts to work around the limitations of your tools. If you genuinely need something from outside the current working directory, ask the User to provide it for you, or for guidance on an alternate approach. Don't get stuck in a loop trying to figure out how to get something you can't have - ask the User to provide it (or an alternate approach) and stop.
|
||||||
|
|
||||||
|
**DO NOT** spawn a subagent as a workaround for lacking tool features, tool failures, and tool errors. Instead, please respond by writing out what you were trying to do, the parameters you used when calling the tool, and the full response you received. Don't work around tool errors. Report faulty tool performance and behavior, then stop. Let the User help determine what to do next.
|
||||||
|
|
||||||
|
## INSTRUCTIONS
|
||||||
|
|
||||||
|
When the user sends you a prompt:
|
||||||
|
|
||||||
|
1. Reason about what they're asking for (or asking you to do).
|
||||||
|
2. Plan out your testing strategy.
|
||||||
|
3. Execute tests, write new tests, fix failing tests, or debug test issues.
|
||||||
|
4. Report test results and any failures with details.
|
||||||
|
|
||||||
|
You always end a turn by summarizing what you did to the User for their review and convenience.
|
||||||
|
|
||||||
|
{{subagent_section}}
|
||||||
|
|
||||||
|
## EXPECTED OUTCOMES
|
||||||
|
|
||||||
|
In Test mode, you will:
|
||||||
|
|
||||||
|
1. Write unit, integration, and end-to-end tests.
|
||||||
|
2. Run test suites and analyze results.
|
||||||
|
3. Debug failing tests and fix test issues.
|
||||||
|
4. Ensure code coverage meets requirements.
|
||||||
|
5. Validate that new features work as expected through testing.
|
||||||
9
gadget-code/data/prompts/common/scope-block.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
Your current working directory is a project that the User is working on with you. You can explore anything in this directory except for secrets and credentials. You cannot explore outside of this directory. If you execute shell commands, you do so only to learn (not to make changes).
|
||||||
|
|
||||||
|
The Gadget Code system operates in one of five modes:
|
||||||
|
|
||||||
|
- Plan: For planning out what needs to be done.
|
||||||
|
- Build: For doing what needs to be done.
|
||||||
|
- Test: For testing what got done.
|
||||||
|
- Ship: For deploying or "shipping" what got tested to be correct.
|
||||||
|
- Develop: A special mode for working on the harness itself (Gadget Code, you).
|
||||||
30
gadget-code/data/prompts/common/subagents.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
## SUBAGENTS
|
||||||
|
|
||||||
|
There are two subagents you can spawn to perform work and save you memory and effort in the session context with the user. Subagents are useful when you know there will be many tool calls involved in an operation.
|
||||||
|
|
||||||
|
The subagents will:
|
||||||
|
|
||||||
|
1. Make many tool calls and pollute their own context with all the extraneous information you don't and won't need
|
||||||
|
2. Perform the work requested, and filter the information in the response down to what you **do** need
|
||||||
|
3. Respond to you with the information or result you need in order to continue working on your tasks.
|
||||||
|
|
||||||
|
Always be specific when asking a subagent to do work. When constructing your prompt for the subagent:
|
||||||
|
|
||||||
|
1. Tell the agent where to begin (if relevant)
|
||||||
|
2. Tell the agent exactly what to do or what to find
|
||||||
|
3. Give the agent the information YOU HAVE that will help it do it's work
|
||||||
|
4. Don't give the agent confusing or misleading directions
|
||||||
|
5. Define "done" for the agent so it knows when to stop working
|
||||||
|
6. Tell the agent exactly how to report back to you when done
|
||||||
|
|
||||||
|
### SUBAGENT: EXPLORE
|
||||||
|
|
||||||
|
The Explore subagent is tuned for knowing how to dig through a project directory, find the requested information, and generate a report back to you containing the information you need (if available).
|
||||||
|
|
||||||
|
Use this subagent to learn about a project's structure, architecture, or actual content such as program source code, documentation, etc.
|
||||||
|
|
||||||
|
### SUBAGENT: GENERAL
|
||||||
|
|
||||||
|
The General subagent can perform finite-scoped and well-defined tasks for you, calling many tools to get work done. It will report back to you with your requested result or status. This saves you from polluting your own session context with all the sub-steps and actions taken, etc., and lets you know that work got done so you can cross tasks off your list without cluttering your thoughts going forward.
|
||||||
|
|
||||||
|
Use the general subagent to have work done, write software and tests, write documentation, and perform project maintenance chores on your behalf. Provide it with clear instructions for what to do, how to do it, and how to respond to you to get work done and the response you need.
|
||||||
35
gadget-code/data/prompts/subagent/explore/system.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
## ROLE
|
||||||
|
|
||||||
|
You are the read-only Explore subagent in Gadget Code. You research and explore code bases and collections of information to look for knowledge that is needed by other agents to do their work.
|
||||||
|
|
||||||
|
## SCOPE
|
||||||
|
|
||||||
|
You are spawned as a child process by the Master Control Console (MCC) in Gadget Code to find specific information and respond with a specific response to provide that information back to the MCC. You follow your instructions and use your tools to find and report the requested information.
|
||||||
|
|
||||||
|
You will generally be tasked with exploring a software repository to learn about the software architecture, how things are imlemented, the structure of a class and it's methods, the order of operations as they are performed by the software, or even just read and summarize documentation looking for a specific piece of knowledge for the MCC.
|
||||||
|
|
||||||
|
{{tool_aggression}}
|
||||||
|
|
||||||
|
This is your job.
|
||||||
|
|
||||||
|
## TOOL USAGE
|
||||||
|
|
||||||
|
Use your tools immediately and aggressively. Do not announce tool usage, ask permission, or describe what you're about to do. Just execute. When you need to check a file, read it. When you need to find something, search for it. When you need to verify something, run the command. Explain your reasoning and results after taking action, not before.
|
||||||
|
|
||||||
|
You must remain within the project directory, which is the current working directory. You cannot access files and data outside of the project directory. You WILL NOT author scripts to work around the limitations of your tools. Don't get stuck in a loop trying to figure out how to get something you can't have - say that you were unable to proceed, and stop.
|
||||||
|
|
||||||
|
**DO NOT** attempt workarounds for lacking tool features, tool failures, and tool errors. Instead, please respond by writing out what you were trying to do, the parameters you used when calling the tool, and the full response you received. Don't work around tool errors. Report faulty tool performance and behavior, then stop.
|
||||||
|
|
||||||
|
## INSTRUCTIONS
|
||||||
|
|
||||||
|
The Master Control Console (MCC) will create an instance of you and prompt you with specific instructions for a task you are to complete.
|
||||||
|
|
||||||
|
1. Reason about the request and figure out what it is that you'll need to do.
|
||||||
|
2. Perform the work requested by the MCC.
|
||||||
|
3. Respond with the information requested by the MCC.
|
||||||
|
|
||||||
|
Keep your output as brief as possible. You are not communicating with a human. You are communicating with a software agent. Communicate the knowledge requested back to the MCC in your response using clear and concise language intended to calibrate the agent for success while doing it's work.
|
||||||
|
|
||||||
|
## CONSTRAINTS
|
||||||
|
|
||||||
|
- DO NOT make any changes to any files under any circumstances - you are a read-only subagent.
|
||||||
40
gadget-code/data/prompts/subagent/general/system.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
## ROLE
|
||||||
|
|
||||||
|
You are the General subagent in Gadget Code. You work in code bases and collections of information to perform general tasks.
|
||||||
|
|
||||||
|
## SCOPE
|
||||||
|
|
||||||
|
You are spawned as a child process by the Master Control Console (MCC) in Gadget Code to perform a specific task and respond with a specific response. You follow your instructions and use your tools to accomplish the work requested by the MCC. This is your job.
|
||||||
|
|
||||||
|
You will generally be tasked with exploring a software repository to learn about the software architecture, how things are imlemented, the structure of a class and it's methods, or even just read and summarize documentation looking for a specific piece of knowledge for the MCC.
|
||||||
|
|
||||||
|
{{tool_aggression}}
|
||||||
|
|
||||||
|
This is your job.
|
||||||
|
|
||||||
|
## TOOL USAGE
|
||||||
|
|
||||||
|
Use your tools immediately and aggressively. Do not announce tool usage, ask permission, or describe what you're about to do. Just execute. When you need to check a file, read it. When you need to find something, search for it. When you need to verify something, run the command. Explain your reasoning and results after taking action, not before.
|
||||||
|
|
||||||
|
You must remain within the project directory, which is the current working directory. You cannot access files and data outside of the project directory. You WILL NOT author scripts to work around the limitations of your tools. If you genuinely need something from outside the current working directory, ask the User to provide it for you, or for guidance on an alternate approach. Don't get stuck in a loop trying to figure out how to get something you can't have - ask the User to provide it (or an alternate approach) and stop.
|
||||||
|
|
||||||
|
**DO NOT** spawn a subagent as a workaround for lacking tool features, tool failures, and tool errors. Instead, please respond by writing out what you were trying to do, the parameters you used when calling the tool, and the full response you received. Don't work around tool errors. Report faulty tool performance and behavior, then stop. Let the User help determine what to do next.
|
||||||
|
|
||||||
|
## INSTRUCTIONS
|
||||||
|
|
||||||
|
The Master Control Console (MCC) will create an instance of you and prompt you with specific instructions for a task you are to complete.
|
||||||
|
|
||||||
|
1. Reason about the request and figure out what it is that you'll need to do.
|
||||||
|
2. Use your tools to perform the work requested by the MCC.
|
||||||
|
3. Respond with the information requested by the MCC.
|
||||||
|
|
||||||
|
Keep your output as brief as possible. You are not communicating with a human. You are communicating with a software agent. Communicate the knowledge requested back to the MCC in your response using clear and concise language intended to calibrate the agent for success while doing it's work.
|
||||||
|
|
||||||
|
You are not permitted to access resources outside of the project working directory and it's subdirectories. If you need something from outside the current working directory, your tools will not be able to provide it. Ask the MCC to provide whatever you need from outside of the project for you. Or, ask the MCC for guidance on an alternate approach, and stop. The MCC will spawn a new subagent with the information needed, or a different approach.
|
||||||
|
|
||||||
|
## CONSTRAINTS
|
||||||
|
|
||||||
|
- You must remain within the project directory, which is the current working directory.
|
||||||
|
- You cannot access files and data outside of the project directory.
|
||||||
|
- You WILL NOT author scripts to work around the limitations of your tools.
|
||||||
|
- Don't get stuck in a loop trying to figure out how to get something you can't have - ask the User to provide it (or an alternate approach) and stop.
|
||||||
25
gadget-code/deploy
Executable file
@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
PRODUCT="gadget-code"
|
||||||
|
PRODUCTION_BRANCH="master"
|
||||||
|
|
||||||
|
echo "-------------------------------------------------------------------------"
|
||||||
|
echo "Updating codebase..."
|
||||||
|
|
||||||
|
git checkout $PRODUCTION_BRANCH
|
||||||
|
git pull origin $PRODUCTION_BRANCH
|
||||||
|
pnpm i
|
||||||
|
|
||||||
|
echo "-------------------------------------------------------------------------"
|
||||||
|
echo "Building application server..."
|
||||||
|
|
||||||
|
./build
|
||||||
|
|
||||||
|
echo "-------------------------------------------------------------------------"
|
||||||
|
echo "Restarting application server..."
|
||||||
|
|
||||||
|
sudo supervisorctl stop "${PRODUCT}-web:*"
|
||||||
|
sudo supervisorctl start "${PRODUCT}-web:*"
|
||||||
|
|
||||||
|
echo "-------------------------------------------------------------------------"
|
||||||
|
echo "Done."
|
||||||
134
gadget-code/docs/agentic-workflow-loop.md
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# The Agentic Workflow Loop
|
||||||
|
|
||||||
|
Gadget Code is a software engineering Integrated Development Environment (IDE) built around an Agentic Workflow Loop (AWL). The User can edit project files by hand, and also ask Gadget to do work for them in the project.
|
||||||
|
|
||||||
|
A technical chat interface is offered as the Chat Session view where the User can send a prompt to Gadget for processing. The prompt is packaged as a Work Order, and sent to an available Gadget Drone for processing. The Agentic Workflow Loop resides and executes within the Gadget Drone.
|
||||||
|
|
||||||
|
The browser is used as a remote control of sorts for Agentic Workflow Loops running in the User's drones on the user's behalf. As the agent is performing work, it emits messages back to the browser (using Socket.IO by way of the web server) to update the User's display in the Chat Session view.
|
||||||
|
|
||||||
|
The loop starts when the User submits a prompt for processing, and ends when an AI API call responds with zero tool calls. If a response contains tool calls, the loop calls the tools, inserts "tool" role response messages in the context, and remains in the loop to submit the context back to the model for additional thinking, response, and tool call responses.
|
||||||
|
|
||||||
|
## The Work Order
|
||||||
|
|
||||||
|
Each Gadget Drone registered by the User implements a named Bull job queue. Gadget Code will create a Work Order job for the Drone to process.
|
||||||
|
|
||||||
|
The Work Order is a JSON object that contains information about the project, AI service provider and model, and also the User's prompt.
|
||||||
|
|
||||||
|
````typescript
|
||||||
|
/*
|
||||||
|
* Chat session context is made of chat messages with a timestamp and role. The
|
||||||
|
* role is "system", "user", "assistant", or "tool". The assistant can have
|
||||||
|
* reasoning or "thinking" content. When thinking content is being included, we
|
||||||
|
* merge the thinking + response by enclosing the thinking content in a
|
||||||
|
* <thinking> element in the content.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* <thinking>I need to research the project...</thinking>
|
||||||
|
* I found the bug!
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface IChatMessage {
|
||||||
|
createdAt: Date;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IWorkOrder {
|
||||||
|
project: {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
gitUrl: string;
|
||||||
|
};
|
||||||
|
session: {
|
||||||
|
name: string;
|
||||||
|
mode: string;
|
||||||
|
};
|
||||||
|
provider: {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
modelId: string;
|
||||||
|
params: {
|
||||||
|
temperature: number;
|
||||||
|
topP: number;
|
||||||
|
topK: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
context: IChatMessage[];
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
````
|
||||||
|
|
||||||
|
## The Workflow Loop
|
||||||
|
|
||||||
|
When a Work Order job is received a Gadget Drone, it performs a series of steps in preparation to work on the prompt contained in the work order.
|
||||||
|
|
||||||
|
1. Check if the workspace directory currently has the project cloned. If not, clone the project from git into the workspace directory.
|
||||||
|
1. Instantiate the correct AI API client object configured with the credentials provided in the work order.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/*
|
||||||
|
* This is just pseudocode that shows the intent. It does not account for every
|
||||||
|
* aspect of the loop.
|
||||||
|
*/
|
||||||
|
async function workflowLoop(
|
||||||
|
session: IChatSession,
|
||||||
|
provider: IAiProvider,
|
||||||
|
llm: string,
|
||||||
|
userPrompt: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const NOW = new Date();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Create the ChatTurn in the database at the *start* of the turn, and update
|
||||||
|
* it with information as the turn executes. This helps ensure we don't lose
|
||||||
|
* work conducted in a turn if an error happens. A turn should be able to be
|
||||||
|
* resumed whenever possible.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const turn = new ChatTurn();
|
||||||
|
turn.createdAt = NOW;
|
||||||
|
turn.status = ChatTurnStatus.Processing;
|
||||||
|
|
||||||
|
turn.user = session.user;
|
||||||
|
turn.provider = provider;
|
||||||
|
turn.llm = llm;
|
||||||
|
turn.mode = session.mode;
|
||||||
|
turn.prompt = userPrompt;
|
||||||
|
|
||||||
|
turn.stats = {
|
||||||
|
toolCallCount: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
thinkintTokenCount: 0,
|
||||||
|
responseTokens: 0,
|
||||||
|
durationMs: 0,
|
||||||
|
durationLabel: "pending",
|
||||||
|
};
|
||||||
|
|
||||||
|
await turn.save();
|
||||||
|
|
||||||
|
/* emit Socket.IO message with turn
|
||||||
|
|
||||||
|
// this turn's context (system, history, prompt, work)
|
||||||
|
const messages = [];
|
||||||
|
|
||||||
|
const systemPrompt = buildSystemPrompt(session);
|
||||||
|
messages.push({ role: "system", content: systemPrompt });
|
||||||
|
|
||||||
|
// recall full session history into messages array
|
||||||
|
buildSessionContext(session, messages);
|
||||||
|
|
||||||
|
// push the User's latest prompt to the context
|
||||||
|
messages.push({ role: "user", content: userPrompt });
|
||||||
|
|
||||||
|
let keepProcessing = true;
|
||||||
|
do {
|
||||||
|
const response = await aiApiCall(messages);
|
||||||
|
keeProcessing = response.tool_calls.length > 0;
|
||||||
|
for (const tool_call of response.tool_calls) {
|
||||||
|
const response = await callTool(tool_call.name, tool_call.args);
|
||||||
|
messages.push({ role: "tool", content: response });
|
||||||
|
}
|
||||||
|
} while (keepProcessing);
|
||||||
|
}
|
||||||
|
```
|
||||||
0
gadget-code/docs/chat-session.md
Normal file
13
gadget-code/docs/gadget-drone.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Gadget Drone
|
||||||
|
|
||||||
|
Gadget Drone is a component in the Gadget Code ecosystem that operates as a headless/non-interactive process on a host in a directory on storage (once authenticated). Gadget Drone:
|
||||||
|
|
||||||
|
1. Assumes ownership of process.cwd() on startup, and verifies that the directory is either already a Gadget Drone directory, or empty. If not a Gadget Drone directory, and if also not empty, Gadget Drone refuses to start with a message printed to the console, and terminates.
|
||||||
|
|
||||||
|
2. Creates the .gadget and .gadget-cache directories in the directory on launch if they are not there.
|
||||||
|
|
||||||
|
## .gadget directory
|
||||||
|
|
||||||
|
When the Gadget Drone starts and initializes,
|
||||||
|
|
||||||
|
## .gadget-cache directory
|
||||||
29
gadget-code/docs/project.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Gadget Code: Projects
|
||||||
|
|
||||||
|
The User will create one or more Project records for tracking a software development project in Gadget Code.
|
||||||
|
|
||||||
|
It will be uncommon to query this model/collection without a User. System administrators and managers will have interest in the global Projects list. Developers will not. A Developer only cares about their own projects.
|
||||||
|
|
||||||
|
It's not a security violation for a User to see another User's projects. It's just superflous information. John doesn't care about Nancy's projects. John works on John's projects, and he owns them.
|
||||||
|
|
||||||
|
## createdAt
|
||||||
|
|
||||||
|
The date and time at which the User created the Project record in Gadget Code. This is not necessarily when the project itself was created or started. This is just when Gadget Code got involved.
|
||||||
|
|
||||||
|
## user
|
||||||
|
|
||||||
|
The User for whom the Project was created. The User is the owner of the project. Users can only see their own Projects. A user is thought of as working in their project. If multiple Users are in the chat, one of them is the Project Owner and will be marked as such. The other Users in the chat session are reffered to as Collaborators.
|
||||||
|
|
||||||
|
## name
|
||||||
|
|
||||||
|
The name of the project as displayed in lists, headers, title areas, etc.
|
||||||
|
|
||||||
|
## slug
|
||||||
|
|
||||||
|
The project's slug (my-project), which is used as it's directory name in the workspace directory by [Gadget Drone](./gadget-drone.md).
|
||||||
|
|
||||||
|
## gitUrl
|
||||||
|
|
||||||
|
The URL of the project's git repository (optional). This will be used to clone the project into the workspace directory on the drones. If this is not set, a new git repo will be created in the workspace directory and the url will be stored here.
|
||||||
|
|
||||||
|
If a gitUrl is not set for a Project and the User selects to delete the project, the project directory will be removed, and it will NOT be pushed to git. The User should be informed that the contents of the directory will be lost, and should have to confirm that this is what they want to do.
|
||||||
13
gadget-code/frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#101317" />
|
||||||
|
<title>Gadget Code</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
gadget-code/frontend/package.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "gadget-code-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Gadget Code Frontend - A self-hosted Agentic Engineering Platform (AEP).",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build"
|
||||||
|
},
|
||||||
|
"author": "Robert Colbert <rob.colbert@openplatform.us>",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
}
|
||||||
5
gadget-code/frontend/postcss.config.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
};
|
||||||
96
gadget-code/frontend/src/App.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { User } from './lib/api';
|
||||||
|
import { socketClient } from './lib/socket';
|
||||||
|
import Navbar from './components/Navbar';
|
||||||
|
import Home from './pages/Home';
|
||||||
|
import SignIn from './pages/SignIn';
|
||||||
|
import SignUp from './pages/SignUp';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'dtp_auth_token';
|
||||||
|
const USER_KEY = 'dtp_user';
|
||||||
|
|
||||||
|
function getStoredUser(): User | null {
|
||||||
|
try {
|
||||||
|
const userData = localStorage.getItem(USER_KEY);
|
||||||
|
return userData ? JSON.parse(userData) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStoredUser(user: User | null) {
|
||||||
|
if (user) {
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedUser = getStoredUser();
|
||||||
|
const storedToken = localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (storedUser && storedToken) {
|
||||||
|
setUser(storedUser);
|
||||||
|
socketClient.connect(storedToken);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSignInSuccess = (newUser: User, token: string) => {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
setStoredUser(newUser);
|
||||||
|
setUser(newUser);
|
||||||
|
socketClient.connect(token);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignOut = () => {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
setStoredUser(null);
|
||||||
|
setUser(null);
|
||||||
|
socketClient.disconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-bg">
|
||||||
|
<div className="text-text-muted">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<div className="min-h-screen bg-bg flex flex-col">
|
||||||
|
<Navbar user={user} onSignOut={handleSignOut} />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home user={user} />} />
|
||||||
|
<Route
|
||||||
|
path="/sign-in"
|
||||||
|
element={
|
||||||
|
user ? (
|
||||||
|
<Navigate to="/" replace />
|
||||||
|
) : (
|
||||||
|
<SignIn onSuccess={handleSignInSuccess} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/sign-up"
|
||||||
|
element={
|
||||||
|
user ? (
|
||||||
|
<Navigate to="/" replace />
|
||||||
|
) : (
|
||||||
|
<SignUp onSuccess={handleSignInSuccess} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
gadget-code/frontend/src/components/Navbar.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
_id: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavbarProps {
|
||||||
|
user: User | null;
|
||||||
|
onSignOut: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Navbar({ user, onSignOut }: NavbarProps) {
|
||||||
|
return (
|
||||||
|
<nav className="bg-bg-secondary border-b border-border">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||||
|
<Link to="/" className="text-xl font-semibold text-text">
|
||||||
|
DTP Web App
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<span className="text-text-muted">{user.displayName}</span>
|
||||||
|
<button
|
||||||
|
onClick={onSignOut}
|
||||||
|
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/sign-in"
|
||||||
|
className="px-4 py-2 text-text-muted hover:text-text transition-colors"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/sign-up"
|
||||||
|
className="px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
gadget-code/frontend/src/index.css
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-bg: #0f0f12;
|
||||||
|
--color-bg-secondary: #1a1a1f;
|
||||||
|
--color-text: #e4e4e7;
|
||||||
|
--color-text-muted: #a1a1aa;
|
||||||
|
--color-primary: #3b82f6;
|
||||||
|
--color-primary-hover: #2563eb;
|
||||||
|
--color-border: #27272a;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
64
gadget-code/frontend/src/lib/api.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
const API_BASE = '';
|
||||||
|
|
||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
user?: T;
|
||||||
|
token?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: Record<string, unknown>
|
||||||
|
): Promise<T> {
|
||||||
|
const options: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
options.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, options);
|
||||||
|
const json = await response.json() as ApiResponse<T>;
|
||||||
|
|
||||||
|
if (!json.success) {
|
||||||
|
throw new Error(json.message || 'Request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For auth endpoints that return {success, user, token}, return the full response
|
||||||
|
if (json.token !== undefined && json.user !== undefined) {
|
||||||
|
return json as T;
|
||||||
|
}
|
||||||
|
if (json.data !== undefined) {
|
||||||
|
return json.data as T;
|
||||||
|
}
|
||||||
|
return json as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string) => request<T>('GET', path),
|
||||||
|
post: <T>(path: string, body: Record<string, unknown>) =>
|
||||||
|
request<T>('POST', path, body),
|
||||||
|
put: <T>(path: string, body: Record<string, unknown>) =>
|
||||||
|
request<T>('PUT', path, body),
|
||||||
|
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
_id: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
flags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
user: User;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
98
gadget-code/frontend/src/lib/socket.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
|
||||||
|
const SOCKET_URL = '';
|
||||||
|
|
||||||
|
export interface SocketEvents {
|
||||||
|
'agent:thinking': (data: { agentId: string; thinking: string }) => void;
|
||||||
|
'agent:response': (data: { agentId: string; chunk: string }) => void;
|
||||||
|
'agent:tool-call': (data: { agentId: string; tool: string; args: unknown }) => void;
|
||||||
|
'agent:tool-result': (data: { agentId: string; tool: string; result: unknown }) => void;
|
||||||
|
'agent:complete': (data: { agentId: string }) => void;
|
||||||
|
'log:entry': (data: { level: string; message: string; timestamp: number }) => void;
|
||||||
|
'chat:message': (data: { agentId: string; message: string; role: 'user' | 'assistant' | 'system' }) => void;
|
||||||
|
connect: () => void;
|
||||||
|
disconnect: () => void;
|
||||||
|
error: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SocketClient {
|
||||||
|
private socket: Socket | null = null;
|
||||||
|
private eventListeners: Map<string, Set<(...args: unknown[]) => void>> = new Map();
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private maxReconnectAttempts = 5;
|
||||||
|
private jwt: string | null = null;
|
||||||
|
|
||||||
|
get connected(): boolean {
|
||||||
|
return this.socket?.connected ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(token: string): void {
|
||||||
|
if (this.socket?.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.jwt = token;
|
||||||
|
|
||||||
|
this.socket = io(SOCKET_URL, {
|
||||||
|
auth: {
|
||||||
|
token: this.jwt,
|
||||||
|
},
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: this.maxReconnectAttempts,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionDelayMax: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.emit('connect');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('disconnect', (reason) => {
|
||||||
|
this.emit('disconnect', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('connect_error', (error) => {
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
this.emit('error', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.disconnect();
|
||||||
|
this.socket = null;
|
||||||
|
this.jwt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on<K extends keyof SocketEvents>(event: K, callback: SocketEvents[K]): void {
|
||||||
|
if (!this.eventListeners.has(event)) {
|
||||||
|
this.eventListeners.set(event, new Set());
|
||||||
|
}
|
||||||
|
this.eventListeners.get(event)!.add(callback as (...args: unknown[]) => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
off<K extends keyof SocketEvents>(event: K, callback: SocketEvents[K]): void {
|
||||||
|
const listeners = this.eventListeners.get(event);
|
||||||
|
if (listeners) {
|
||||||
|
listeners.delete(callback as (...args: unknown[]) => void);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<K extends keyof SocketEvents>(event: K, ...args: Parameters<SocketEvents[K]>): void {
|
||||||
|
const listeners = this.eventListeners.get(event);
|
||||||
|
if (listeners) {
|
||||||
|
listeners.forEach((callback) => callback(...args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send<K extends keyof SocketEvents>(event: K, ...args: Parameters<SocketEvents[K]>): void {
|
||||||
|
if (this.socket?.connected) {
|
||||||
|
this.socket.emit(event, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const socketClient = new SocketClient();
|
||||||
10
gadget-code/frontend/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
48
gadget-code/frontend/src/pages/Home.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { api, User } from "../lib/api";
|
||||||
|
|
||||||
|
interface HomeProps {
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home({ user }: HomeProps) {
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-bg">
|
||||||
|
<div className="text-center max-w-lg p-8">
|
||||||
|
<h1 className="text-4xl font-bold mb-4">Gadget Code</h1>
|
||||||
|
<p className="text-xl text-text-muted mb-8">
|
||||||
|
Agentic Engineering IDE
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-4 justify-center">
|
||||||
|
{/* <a
|
||||||
|
href="/sign-up"
|
||||||
|
className="px-6 py-3 bg-primary hover:bg-primary-hover text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Sign Up Today!
|
||||||
|
</a> */}
|
||||||
|
<a
|
||||||
|
href="/sign-in"
|
||||||
|
className="px-6 py-3 border border-border hover:bg-bg-secondary rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center bg-bg">
|
||||||
|
<div className="text-center max-w-lg p-8">
|
||||||
|
<h1 className="text-3xl font-bold mb-4">
|
||||||
|
Welcome, {user.displayName}!
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-text-muted">
|
||||||
|
And then this is where you write some code to build an app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
gadget-code/frontend/src/pages/SignIn.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api, AuthResponse, User } from '../lib/api';
|
||||||
|
|
||||||
|
interface SignInProps {
|
||||||
|
onSuccess: (user: User, token: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignIn({ onSuccess }: SignInProps) {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.post<AuthResponse>('/auth/sign-in', { email, password });
|
||||||
|
onSuccess(response.user, response.token);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Invalid credentials');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-bg">
|
||||||
|
<div className="w-full max-w-md p-8">
|
||||||
|
<h1 className="text-2xl font-bold text-center mb-6">Sign In</h1>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm text-text-muted mb-1">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-bg-secondary border border-border rounded-lg text-text focus:outline-none focus:border-primary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm text-text-muted mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-bg-secondary border border-border rounded-lg text-text focus:outline-none focus:border-primary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 pt-2">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex-1 px-4 py-2 text-center border border-border rounded-lg hover:bg-bg-secondary transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p className="mt-4 text-center text-text-muted">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/sign-up" className="text-primary hover:underline">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
gadget-code/frontend/src/pages/SignUp.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api, AuthResponse, User } from '../lib/api';
|
||||||
|
|
||||||
|
interface SignUpProps {
|
||||||
|
onSuccess: (user: User, token: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignUp({ onSuccess }: SignUpProps) {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [passwordVerify, setPasswordVerify] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (password !== passwordVerify) {
|
||||||
|
setError('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.post<AuthResponse>('/auth/sign-up', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
displayName,
|
||||||
|
});
|
||||||
|
onSuccess(response.user, response.token);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Sign up failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-bg">
|
||||||
|
<div className="w-full max-w-md p-8">
|
||||||
|
<h1 className="text-2xl font-bold text-center mb-6">Create Account</h1>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm text-text-muted mb-1">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-bg-secondary border border-border rounded-lg text-text focus:outline-none focus:border-primary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="displayName" className="block text-sm text-text-muted mb-1">
|
||||||
|
Display Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="displayName"
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-bg-secondary border border-border rounded-lg text-text focus:outline-none focus:border-primary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm text-text-muted mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-bg-secondary border border-border rounded-lg text-text focus:outline-none focus:border-primary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="passwordVerify" className="block text-sm text-text-muted mb-1">
|
||||||
|
Verify Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="passwordVerify"
|
||||||
|
type="password"
|
||||||
|
value={passwordVerify}
|
||||||
|
onChange={(e) => setPasswordVerify(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-bg-secondary border border-border rounded-lg text-text focus:outline-none focus:border-primary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 pt-2">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex-1 px-4 py-2 text-center border border-border rounded-lg hover:bg-bg-secondary transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 px-4 py-2 bg-primary hover:bg-primary-hover text-white rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Creating...' : 'Sign Up'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
gadget-code/frontend/tailwind.config.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
bg: '#0f0f12',
|
||||||
|
'bg-secondary': '#1a1a1f',
|
||||||
|
text: '#e4e4e7',
|
||||||
|
'text-muted': '#a1a1aa',
|
||||||
|
primary: '#3b82f6',
|
||||||
|
'primary-hover': '#2563eb',
|
||||||
|
border: '#27272a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config;
|
||||||
56
gadget-code/frontend/vite.config.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rootDir = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
root: '.',
|
||||||
|
publicDir: 'public',
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
https: {
|
||||||
|
key: fs.readFileSync(path.join(rootDir, 'ssl', 'code-dev.g4dge7.com.key')),
|
||||||
|
cert: fs.readFileSync(path.join(rootDir, 'ssl', 'code-dev.g4dge7.com.crt')),
|
||||||
|
},
|
||||||
|
hmr: {
|
||||||
|
host: 'code-dev.g4dge7.com',
|
||||||
|
port: 5174,
|
||||||
|
protocol: 'wss',
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
'/auth': {
|
||||||
|
target: 'http://localhost:3443',
|
||||||
|
changeOrigin: true,
|
||||||
|
cookieDomainRewrite: {
|
||||||
|
'': 'code-dev.g4dge7.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3443',
|
||||||
|
changeOrigin: true,
|
||||||
|
cookieDomainRewrite: {
|
||||||
|
'': 'code-dev.g4dge7.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/socket.io': {
|
||||||
|
target: 'http://localhost:3443',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: path.join(rootDir, 'dist', 'client'),
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.join(rootDir, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
8
gadget-code/gadget-code.code-workspace
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": ".",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"settings": {},
|
||||||
|
}
|
||||||
44
gadget-code/nginx/webapp.digitaltelepresence.com
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
upstream webapp {
|
||||||
|
least_conn;
|
||||||
|
server 127.0.0.1:3600;
|
||||||
|
server 127.0.0.1:3601;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
server_name webapp.digitaltelepresence.com
|
||||||
|
|
||||||
|
location /assets {
|
||||||
|
root /home/dtp/live/dtp-webapp;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
|
||||||
|
proxy_pass http://webapp;
|
||||||
|
proxy_max_temp_file_size 0;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl ipv6only=on;
|
||||||
|
|
||||||
|
ssl_certificate /home/dtp/keys/cloudflare-origin.pem
|
||||||
|
ssl_certificate_key /home/dtp/keys/cloudflare-origin.key
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
server_name webapp.digitaltelepresence.com;
|
||||||
|
|
||||||
|
if ($host = webapp.digitaltelepresence.com) {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
101
gadget-code/package.json
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"name": "gadget-code",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Gadget Code - A self-hosted Agentic Engineering Platform (AEP).",
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "pnpm build:backend && pnpm build:frontend",
|
||||||
|
"build:backend": "pnpm tsc && pnpm tsc-alias",
|
||||||
|
"build:frontend": "cd frontend && pnpm build",
|
||||||
|
"dev": "tsx ./src/web-app.ts",
|
||||||
|
"dev:backend": "tsx ./src/web-app.ts",
|
||||||
|
"dev:backend:watch": "tsx watch ./src/web-app.ts",
|
||||||
|
"dev:frontend": "cd frontend && vite",
|
||||||
|
"cli": "NODE_ENV=production node ./dist/web-cli.js",
|
||||||
|
"start": "NODE_ENV=production node ./dist/web-app.js",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "Robert Colbert <rob.colbert@openplatform.us>",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"packageManager": "pnpm@10.12.3",
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
|
"@gadget/ai": "workspace:*",
|
||||||
|
"ansicolor": "^2.0.3",
|
||||||
|
"bull": "^4.16.5",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
|
"compression": "^1.8.0",
|
||||||
|
"connect-redis": "^8.0.2",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cron": "^4.3.1",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"dotenv": "^16.6.0",
|
||||||
|
"dtp-cleantext": "^1.0.0",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"express-rate-limit": "^7.5.1",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
|
"geoip-lite": "^1.4.10",
|
||||||
|
"has-flag": "^5.0.1",
|
||||||
|
"ioredis": "^5.6.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"marked": "^16.0.0",
|
||||||
|
"method-override": "^3.0.0",
|
||||||
|
"minio": "^8.0.5",
|
||||||
|
"mongoose": "^8.16.1",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"multer": "^2.0.1",
|
||||||
|
"nodemailer": "^7.0.3",
|
||||||
|
"numeral": "^2.0.6",
|
||||||
|
"pug": "^3.0.3",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"react-router-dom": "^7.14.2",
|
||||||
|
"rotating-file-stream": "^3.2.6",
|
||||||
|
"serve-favicon": "^2.5.1",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
|
"uikit": "^3.23.11",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@tailwindcss/postcss": "^4.2.4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/browser-sync": "^2.29.0",
|
||||||
|
"@types/compression": "^1.8.1",
|
||||||
|
"@types/cookie-parser": "^1.4.9",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/express-session": "^1.18.2",
|
||||||
|
"@types/geoip-lite": "^1.4.4",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/less": "^3.0.8",
|
||||||
|
"@types/method-override": "^3.0.0",
|
||||||
|
"@types/morgan": "^1.9.10",
|
||||||
|
"@types/multer": "^1.4.13",
|
||||||
|
"@types/node": "^24.0.4",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
|
"@types/numeral": "^2.0.5",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/serve-favicon": "^2.5.7",
|
||||||
|
"@types/uikit": "^3.14.5",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"browser-sync": "^3.0.4",
|
||||||
|
"esbuild": "^0.25.5",
|
||||||
|
"globals": "^16.2.0",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
|
"less": "^4.3.0",
|
||||||
|
"postcss": "^8.5.10",
|
||||||
|
"tailwindcss": "^4.2.4",
|
||||||
|
"tsc-alias": "^1.0.7",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^8.0.10",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
gadget-code/playwright.config.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
timeout: 60000,
|
||||||
|
use: {
|
||||||
|
baseURL: 'https://code-dev.g4dge7.com:5174',
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
headless: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
5981
gadget-code/pnpm-lock.yaml
Normal file
4
gadget-code/pnpm-workspace.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
|
- msgpackr-extract
|
||||||
|
- vue-demi
|
||||||
54
gadget-code/release
Executable file
@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Exit immediately if a command exits with a non-zero status
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PRODUCTION_BRANCH="master"
|
||||||
|
DEVELOP_BRANCH="develop"
|
||||||
|
|
||||||
|
REMOTE_NAME=${2:-origin}
|
||||||
|
RELEASE_TYPE=$1
|
||||||
|
|
||||||
|
# 1. Check for required argument
|
||||||
|
if [ -z "$RELEASE_TYPE" ]; then
|
||||||
|
echo "❌ Error: Must specify release type (major, minor, patch)."
|
||||||
|
echo "Usage: ./release <type> [remote_name]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Verify the remote exists
|
||||||
|
if ! git remote | grep -q "^$REMOTE_NAME$"; then
|
||||||
|
echo "❌ Error: Remote '$REMOTE_NAME' not found."
|
||||||
|
echo "Available remotes: $(git remote | tr '\n' ' ')"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Ensure working directory is clean (no uncommitted changes)
|
||||||
|
if [[ -n $(git status --porcelain) ]]; then
|
||||||
|
echo "❌ Error: Your working directory is dirty. Commit or stash changes first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 Starting release on $REMOTE_NAME..."
|
||||||
|
|
||||||
|
# 4. Sync Develop
|
||||||
|
git checkout "$DEVELOP_BRANCH"
|
||||||
|
git pull "$REMOTE_NAME" "$DEVELOP_BRANCH"
|
||||||
|
npm version "$RELEASE_TYPE"
|
||||||
|
git push "$REMOTE_NAME" "$DEVELOP_BRANCH" --follow-tags
|
||||||
|
|
||||||
|
# 5. Sync Production
|
||||||
|
git checkout "$PRODUCTION_BRANCH"
|
||||||
|
git pull "$REMOTE_NAME" "$PRODUCTION_BRANCH"
|
||||||
|
|
||||||
|
# Merge develop into production locally
|
||||||
|
# Using --no-ff preserves the release history in the git graph
|
||||||
|
git merge "$DEVELOP_BRANCH" --no-edit
|
||||||
|
|
||||||
|
# 6. Push Production
|
||||||
|
git push "$REMOTE_NAME" "$PRODUCTION_BRANCH"
|
||||||
|
|
||||||
|
# 7. Return to Develop
|
||||||
|
git checkout "$DEVELOP_BRANCH"
|
||||||
|
|
||||||
|
echo "✅ Release complete!"
|
||||||
34
gadget-code/src/config/browsersync.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// browsersync.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import env from "./env.js";
|
||||||
|
|
||||||
|
import { Options as BrowserSyncOptions } from "browser-sync";
|
||||||
|
|
||||||
|
const options: BrowserSyncOptions = {
|
||||||
|
proxy: {
|
||||||
|
target: `https://${env.site.host}/`,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
host: env.site.domain,
|
||||||
|
open: false,
|
||||||
|
https: {
|
||||||
|
key: env.https.keyFile,
|
||||||
|
cert: env.https.crtFile,
|
||||||
|
},
|
||||||
|
port: 3000,
|
||||||
|
cors: true,
|
||||||
|
ui: {
|
||||||
|
port: 3620,
|
||||||
|
},
|
||||||
|
notify: false,
|
||||||
|
ghostMode: {
|
||||||
|
clicks: false,
|
||||||
|
forms: false,
|
||||||
|
scroll: true,
|
||||||
|
},
|
||||||
|
logLevel: "info",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default options;
|
||||||
167
gadget-code/src/config/env.ts
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
// config/env.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import "dotenv/config";
|
||||||
|
import type PackageJson from "../../package.json";
|
||||||
|
|
||||||
|
import assert from "node:assert";
|
||||||
|
assert(process.env.DTP_USER_PASSWORD_SALT, "must define password salt in .env");
|
||||||
|
assert(process.env.DTP_JWT_SECRET, "must define JSON Web Token secret");
|
||||||
|
|
||||||
|
import path, { dirname } from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line
|
||||||
|
|
||||||
|
export const ROOT_DIR = path.resolve(__dirname, "..", "..");
|
||||||
|
export const SRC_DIR = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
async function readJsonFile<T>(path: string): Promise<T> {
|
||||||
|
const file = await fs.promises.readFile(path);
|
||||||
|
return JSON.parse(file.toString("utf-8")) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISiteDefinition {
|
||||||
|
company: string;
|
||||||
|
companyShort: string;
|
||||||
|
name: string;
|
||||||
|
shortName: string;
|
||||||
|
tagline: string;
|
||||||
|
sloagn: string;
|
||||||
|
description: string;
|
||||||
|
domain: string;
|
||||||
|
domainKey: string;
|
||||||
|
host: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
|
timezone: process.env.DTP_TIMEZONE || "America/New_York",
|
||||||
|
root: ROOT_DIR,
|
||||||
|
src: SRC_DIR,
|
||||||
|
pkg: await readJsonFile<typeof PackageJson>(
|
||||||
|
path.join(ROOT_DIR, "package.json"),
|
||||||
|
),
|
||||||
|
site: {
|
||||||
|
company: process.env.DTP_SITE_COMPANY || "Robert Colbert",
|
||||||
|
companyShort: process.env.DTP_SITE_COMPANY_SHORT || "Colbert",
|
||||||
|
name: process.env.DTP_SITE_NAME || "Gadget Code",
|
||||||
|
shortName: process.env.DTP_SITE_NAME || "Gadget Code",
|
||||||
|
slogan: process.env.DTP_SITE_SLOGAN || "Self-hosted Agentic Engineering Platform",
|
||||||
|
description:
|
||||||
|
process.env.DTP_SITE_DESCRIPTION ||
|
||||||
|
"Gadget Code - A self-hosted Agentic Engineering Platform (AEP).",
|
||||||
|
domain: process.env.DTP_SITE_DOMAIN || "code-dev.g4dge7.com",
|
||||||
|
domainKey:
|
||||||
|
process.env.DTP_SITE_DOMAIN_KEY || "code-dev.g4dge7.com",
|
||||||
|
host: process.env.DTP_SITE_HOST || "code-dev.g4dge7.com",
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
ollama: {
|
||||||
|
apiUrl: process.env.DTP_OLLAMA_API_URL || "http://localhost:11434",
|
||||||
|
apiKey: process.env.DTP_OLLAMA_API_KEY || "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
jwtSecret: process.env.DTP_JWT_SECRET,
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
secret: process.env.DTP_SESSION_SECRET,
|
||||||
|
trustProxy:
|
||||||
|
process.env.NODE_ENV === "production" ||
|
||||||
|
process.env.DTP_SESSION_TRUST_PROXY === "enabled",
|
||||||
|
cookie: {
|
||||||
|
secure: process.env.DTP_SESSION_COOKIE_SECURE === "enabled",
|
||||||
|
sameSite: process.env.DTP_SESSION_COOKIE_SAMESITE || false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mongodb: {
|
||||||
|
host: process.env.DTP_MONGODB_HOST || "localhost",
|
||||||
|
database: process.env.DTP_MONGODB_DATABASE || "",
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
host: process.env.DTP_REDIS_HOST || "localhost",
|
||||||
|
port: parseInt(process.env.DTP_REDIS_PORT || "6379", 10),
|
||||||
|
password: process.env.DTP_REDIS_PASSWORD,
|
||||||
|
keyPrefix: process.env.DTP_REDIS_KEY_PREFIX || "dtp",
|
||||||
|
lazyConnect: process.env.DTP_REDIS_LAZYCONNECT === "enabled",
|
||||||
|
},
|
||||||
|
minio: {
|
||||||
|
endpoint: process.env.DTP_MINIO_ENDPOINT || "localhost",
|
||||||
|
port: parseInt(process.env.DTP_MINIO_PORT || "9080", 10),
|
||||||
|
useSsl: process.env.DTP_MINIO_USE_SSL === "enabled",
|
||||||
|
accessKey: process.env.DTP_MINIO_ACCESS_KEY,
|
||||||
|
secretKey: process.env.DTP_MINIO_SECRET_KEY,
|
||||||
|
buckets: {
|
||||||
|
uploads: process.env.DTP_MINIO_UPLOAD_BUCKET || "dtp-uploads",
|
||||||
|
images: process.env.DTP_MINIO_IMAGE_BUCKET || "dtp-images",
|
||||||
|
videos: process.env.DTP_MINIO_VIDEO_BUCKET || "dtp-videos",
|
||||||
|
audios: process.env.DTP_MINIO_AUDIO_BUCKET || "dtp-audios",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
signupEnabled: process.env.DTP_USER_SIGNUP === "enabled",
|
||||||
|
passwordSalt: process.env.DTP_USER_PASSWORD_SALT,
|
||||||
|
},
|
||||||
|
https: {
|
||||||
|
enabled: process.env.DTP_HTTPS === "enabled",
|
||||||
|
address: process.env.DTP_HTTPS_HOST || "127.0.0.1",
|
||||||
|
port: parseInt(process.env.DTP_HTTPS_PORT || "3443", 10),
|
||||||
|
backlog: parseInt(process.env.DTP_HTTPS_BACKLOG || "16", 10),
|
||||||
|
keyFile: process.env.DTP_HTTPS_KEY_FILE,
|
||||||
|
crtFile: process.env.DTP_HTTPS_CRT_FILE,
|
||||||
|
uploadPath: process.env.DTP_HTTPS_UPLOAD_PATH || "/tmp",
|
||||||
|
},
|
||||||
|
frontend: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
enabled: process.env.DTP_EMAIL_SERVICE === "enabled",
|
||||||
|
smtp: {
|
||||||
|
host: process.env.DTP_EMAIL_SMTP_HOST || "localhost",
|
||||||
|
port: parseInt(process.env.DTP_EMAIL_SMTP_PORT || "465", 10),
|
||||||
|
secure: process.env.DTP_EMAIL_SMTP_SECURE === "enabled",
|
||||||
|
from:
|
||||||
|
process.env.DTP_EMAIL_SMTP_FROM ||
|
||||||
|
"Digital Telepresence Support <support@digitaltelepresence.com>",
|
||||||
|
user: process.env.DTP_EMAIL_SMTP_USER,
|
||||||
|
password: process.env.DTP_EMAIL_SMTP_PASS,
|
||||||
|
pool: {
|
||||||
|
enabled: process.env.DTP_EMAIL_SMTP_POOL_ENABLED === "enabled",
|
||||||
|
maxConnections: parseInt(
|
||||||
|
process.env.DTP_EMAIL_SMTP_POOL_MAX_CONN || "5",
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
maxMessages: parseInt(
|
||||||
|
process.env.DTP_EMAIL_SMTP_POOL_MAX_MSGS || "100",
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
to:
|
||||||
|
process.env.DTP_EMAIL_CONTACT_TO ||
|
||||||
|
"DTP Support <support@digitaltelepresence.com>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
https: {
|
||||||
|
enabled: process.env.DTP_LOG_HTTPS === "enabled" || false,
|
||||||
|
name: process.env.DTP_LOG_HTTPS_NAME || "gadget-code-https.log",
|
||||||
|
path: process.env.DTP_LOG_HTTPS_PATH || "/var/log/dtp",
|
||||||
|
format: process.env.DTP_LOG_HTTPS_FORMAT || "combined",
|
||||||
|
},
|
||||||
|
console: {
|
||||||
|
enabled: process.env.DTP_LOG_CONSOLE === "enabled",
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
enabled: process.env.DTP_LOG_FILE === "enabled",
|
||||||
|
},
|
||||||
|
levels: {
|
||||||
|
debug: process.env.DTP_LOG_DEBUG === "enabled",
|
||||||
|
info: process.env.DTP_LOG_INFO === "enabled",
|
||||||
|
warn: process.env.DTP_LOG_WARN === "enabled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
33
gadget-code/src/controllers/api.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// src/controllers/api.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import env from "../config/env.js";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { DtpController } from "../lib/controller.js";
|
||||||
|
|
||||||
|
export class ApiController extends DtpController {
|
||||||
|
get name(): string {
|
||||||
|
return "ApiController";
|
||||||
|
}
|
||||||
|
get slug(): string {
|
||||||
|
return "api";
|
||||||
|
}
|
||||||
|
get route(): string {
|
||||||
|
return "/api";
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
const envDir = env.NODE_ENV === "production" ? "dist" : "src";
|
||||||
|
await this.loadChild(
|
||||||
|
path.join(env.root, envDir, "controllers", "api", "v1.js")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiController;
|
||||||
39
gadget-code/src/controllers/api/v1.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// src/controllers/api/v1.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import env from "../../config/env.js";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { DtpController } from "../../lib/controller.js";
|
||||||
|
|
||||||
|
export class ApiControllerV1 extends DtpController {
|
||||||
|
get name(): string {
|
||||||
|
return "ApiControllerV1";
|
||||||
|
}
|
||||||
|
get slug(): string {
|
||||||
|
return "v1";
|
||||||
|
}
|
||||||
|
get route(): string {
|
||||||
|
return "/v1";
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
let basePath;
|
||||||
|
if (env.NODE_ENV === "production") {
|
||||||
|
basePath = path.join(env.root, "dist", "controllers", "api", "v1");
|
||||||
|
} else {
|
||||||
|
basePath = path.join(env.root, "src", "controllers", "api", "v1");
|
||||||
|
}
|
||||||
|
await this.loadChild(path.join(basePath, "auth.js"));
|
||||||
|
await this.loadChild(path.join(basePath, "contact.js"));
|
||||||
|
await this.loadChild(path.join(basePath, "drone.js"));
|
||||||
|
await this.loadChild(path.join(basePath, "user.js"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiControllerV1;
|
||||||
142
gadget-code/src/controllers/api/v1/auth.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
// src/controllers/api/v1/auth.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
// import env from "../config/env.js";
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
|
||||||
|
import { DtpController } from "../../../lib/controller.js";
|
||||||
|
|
||||||
|
import UserService from "../../../services/user.js";
|
||||||
|
import SessionService, { SessionType } from "../../../services/session.js";
|
||||||
|
|
||||||
|
export class AuthApiControllerV1 extends DtpController {
|
||||||
|
get name(): string {
|
||||||
|
return "AuthApiControllerV1";
|
||||||
|
}
|
||||||
|
get slug(): string {
|
||||||
|
return "authV1";
|
||||||
|
}
|
||||||
|
get route(): string {
|
||||||
|
return "/auth";
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.router.post("/sign-in", this.postSignIn.bind(this));
|
||||||
|
this.router.post("/renew-token", this.postRenewToken.bind(this));
|
||||||
|
this.router.get("/sign-out", this.getSignOut.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async postSignIn(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.log.info("authenticating user account", {
|
||||||
|
email: req.body.email,
|
||||||
|
});
|
||||||
|
const user = await UserService.authenticate(
|
||||||
|
req.body.email,
|
||||||
|
req.body.password
|
||||||
|
);
|
||||||
|
req.session.user = {
|
||||||
|
_id: user._id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
flags: user.flags,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.log.info("creating JSON Web Token for user session", {
|
||||||
|
user: { _id: user._id },
|
||||||
|
});
|
||||||
|
const token = await SessionService.createJsonWebToken(user);
|
||||||
|
req.session.token = token;
|
||||||
|
req.session.type = SessionType.JWT;
|
||||||
|
|
||||||
|
req.session.save((err: Error): void => {
|
||||||
|
if (err) {
|
||||||
|
res.status(err.statusCode || 500).json({
|
||||||
|
success: false,
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
_id: user._id.toString(),
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
flags: user.flags,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to process user sign-in", { error });
|
||||||
|
res.status((error as Error).statusCode || 500).json({
|
||||||
|
success: false,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async postRenewToken(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const user = await SessionService.verifyJsonWebToken(req.body.token);
|
||||||
|
const token = await SessionService.createJsonWebToken(user);
|
||||||
|
req.session.token = token;
|
||||||
|
this.log.info("user session token renewed", {
|
||||||
|
user: {
|
||||||
|
_id: user._id,
|
||||||
|
displayName: user.displayName,
|
||||||
|
flags: user.flags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.status(200).json({ success: true, token });
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to process token renewal", { error });
|
||||||
|
res.status((error as Error).statusCode || 500).json({
|
||||||
|
success: false,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSignOut(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (req.session.token) {
|
||||||
|
try {
|
||||||
|
await SessionService.revokeJsonWebToken(req.session.token);
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to revoke user session", { error });
|
||||||
|
res.status((error as Error).statusCode || 500).json({
|
||||||
|
success: false,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.session.destroy((err: Error): void => {
|
||||||
|
if (err) {
|
||||||
|
this.log.error("failed to destroy user session", { error: err });
|
||||||
|
res.status(200).json({
|
||||||
|
success: false,
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (user) {
|
||||||
|
this.log.info("user session destroyed (sign out)", {
|
||||||
|
user: { _id: user._id },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.log.info("userless session destroyed (sign out)");
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthApiControllerV1;
|
||||||
82
gadget-code/src/controllers/api/v1/contact.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
// src/controllers/api/v1/contact.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
// import env from "../../../config/env.js";
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
// import ContactService from "../../../services/contact.js";
|
||||||
|
|
||||||
|
import { DtpController } from "../../../lib/controller.js";
|
||||||
|
|
||||||
|
export class ContactApiControllerV1 extends DtpController {
|
||||||
|
get name(): string {
|
||||||
|
return "ContactApiControllerV1";
|
||||||
|
}
|
||||||
|
get slug(): string {
|
||||||
|
return "contactV1";
|
||||||
|
}
|
||||||
|
get route(): string {
|
||||||
|
return "/contact";
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
const upload = this.createMulter(this.slug, {
|
||||||
|
dest: "/tmp/",
|
||||||
|
limits: {
|
||||||
|
fileSize: 1024 * 1024 * 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const limiter = this.createLimiter(
|
||||||
|
60 * 60, // 1-hour time window
|
||||||
|
3,
|
||||||
|
"Too many messages sent. Please try again later.",
|
||||||
|
(req: Request, _res: Response) => {
|
||||||
|
if (!req.body || !req.body.email) {
|
||||||
|
return req.ip;
|
||||||
|
}
|
||||||
|
return req.ip + req.body.email;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.router.post(
|
||||||
|
"/send-email",
|
||||||
|
limiter,
|
||||||
|
upload.single("attachment"),
|
||||||
|
this.postSendEmail.bind(this)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async postSendEmail(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
// const jobs = [
|
||||||
|
// ContactService.sendContactUsMessage(
|
||||||
|
// req,
|
||||||
|
// env.email.contact.to,
|
||||||
|
// `You have received a contact message at ${env.site.name}.`
|
||||||
|
// ),
|
||||||
|
// ContactService.sendContactUsMessage(
|
||||||
|
// req,
|
||||||
|
// req.body.email,
|
||||||
|
// `${env.site.name} has received your message. A representative will be in touch as soon as possible.`
|
||||||
|
// ),
|
||||||
|
// ];
|
||||||
|
// await Promise.all(jobs);
|
||||||
|
|
||||||
|
res.status(200).json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to send email message", { error });
|
||||||
|
res.status((error as Error).statusCode || 500).json({
|
||||||
|
success: false,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContactApiControllerV1;
|
||||||
99
gadget-code/src/controllers/api/v1/drone.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
// src/controllers/api/v1/drone.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
|
||||||
|
import DroneService from "../../../services/drone.ts";
|
||||||
|
import UserService from "../../../services/user.ts";
|
||||||
|
|
||||||
|
import { DtpController } from "../../../lib/controller.js";
|
||||||
|
import { populateDroneRegistrationById } from "@/controllers/lib/populators.js";
|
||||||
|
|
||||||
|
export class DroneApiControllerV1 extends DtpController {
|
||||||
|
get name(): string {
|
||||||
|
return "DroneApiControllerV1";
|
||||||
|
}
|
||||||
|
get slug(): string {
|
||||||
|
return "ctrl:drone:v1";
|
||||||
|
}
|
||||||
|
get route(): string {
|
||||||
|
return "/drone";
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
const multer = this.createMulter(this.slug, {});
|
||||||
|
this.router.param("registrationId", populateDroneRegistrationById(this));
|
||||||
|
|
||||||
|
this.router.post(
|
||||||
|
"/registration",
|
||||||
|
multer.none(),
|
||||||
|
this.postRegistration.bind(this),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.router.put(
|
||||||
|
"/registration/:registrationId/status",
|
||||||
|
this.putRegistrationStatus.bind(this),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.router.delete(
|
||||||
|
"/registration/:registrationId",
|
||||||
|
this.deleteRegistration.bind(this),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async postRegistration(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const user = await UserService.authenticate(
|
||||||
|
req.body.email,
|
||||||
|
req.body.password,
|
||||||
|
);
|
||||||
|
const registration = await DroneService.register(user, req.body);
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: registration,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to process drone registration", { error });
|
||||||
|
res.status((error as Error).statusCode || 500).json({
|
||||||
|
success: false,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async putRegistrationStatus(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
await DroneService.setStatus(res.locals.registration, req.body.status);
|
||||||
|
res.status(200).json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to update drone status", {
|
||||||
|
_id: res.locals.registration._id.toHexString(),
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
res.status((error as Error).statusCode || 500).json({
|
||||||
|
success: false,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRegistration(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
await DroneService.unregister(res.locals.registration);
|
||||||
|
res.status(200).json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to unregister drone", { error });
|
||||||
|
res.status((error as Error).statusCode || 500).json({
|
||||||
|
success: false,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DroneApiControllerV1;
|
||||||
40
gadget-code/src/controllers/api/v1/user.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
// src/controllers/api/v1/user.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
|
||||||
|
import { DtpController } from "../../../lib/controller.js";
|
||||||
|
|
||||||
|
export class UserApiControllerV1 extends DtpController {
|
||||||
|
get name(): string {
|
||||||
|
return "UserApiControllerV1";
|
||||||
|
}
|
||||||
|
get slug(): string {
|
||||||
|
return "userV1";
|
||||||
|
}
|
||||||
|
get route(): string {
|
||||||
|
return "/user";
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.router.use(this.requireUser());
|
||||||
|
|
||||||
|
this.router.get("/", this.getUser.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUser(req: Request, res: Response): Promise<void> {
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: req.user,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserApiControllerV1;
|
||||||
181
gadget-code/src/controllers/auth.ts
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
// src/controllers/auth.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
// import env from "../config/env.js";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
|
import { DtpController } from "../lib/controller.js";
|
||||||
|
|
||||||
|
import UserService from "../services/user.js";
|
||||||
|
import SessionService, { SessionType } from "../services/session.js";
|
||||||
|
|
||||||
|
export class AuthController extends DtpController {
|
||||||
|
get name(): string {
|
||||||
|
return "AuthController";
|
||||||
|
}
|
||||||
|
get slug(): string {
|
||||||
|
return "auth";
|
||||||
|
}
|
||||||
|
get route(): string {
|
||||||
|
return "/auth";
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.router.post("/sign-up", this.postSignUp.bind(this));
|
||||||
|
this.router.post("/sign-in", this.postSignIn.bind(this));
|
||||||
|
this.router.post("/renew-token", this.postRenewToken.bind(this));
|
||||||
|
|
||||||
|
this.router.get("/welcome", this.getWelcomeView.bind(this));
|
||||||
|
this.router.get("/sign-up", this.getSignUpForm.bind(this));
|
||||||
|
this.router.get("/sign-in", this.getSignInForm.bind(this));
|
||||||
|
this.router.get("/sign-out", this.getSignOut.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async postSignUp(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const user = await UserService.create(
|
||||||
|
req.body.email,
|
||||||
|
req.body.password,
|
||||||
|
req.body.displayName
|
||||||
|
);
|
||||||
|
|
||||||
|
req.session.user = {
|
||||||
|
_id: user._id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
flags: user.flags,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = await SessionService.createJsonWebToken(user);
|
||||||
|
req.session.token = token;
|
||||||
|
req.session.type = SessionType.WEB;
|
||||||
|
|
||||||
|
req.session.save((err: Error) => {
|
||||||
|
if (err) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
_id: user._id.toString(),
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
flags: user.flags,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to process new user sign-up", { error });
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async postSignIn(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const user = await UserService.authenticate(
|
||||||
|
req.body.email,
|
||||||
|
req.body.password
|
||||||
|
);
|
||||||
|
req.session.user = {
|
||||||
|
_id: user._id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
flags: user.flags,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = await SessionService.createJsonWebToken(user);
|
||||||
|
req.session.token = token;
|
||||||
|
req.session.type = SessionType.WEB;
|
||||||
|
|
||||||
|
req.session.save((err: Error) => {
|
||||||
|
if (err) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
_id: user._id.toString(),
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
flags: user.flags,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async postRenewToken(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const user = await SessionService.verifyJsonWebToken(req.body.token);
|
||||||
|
const token = await SessionService.createJsonWebToken(user);
|
||||||
|
req.session.token = token;
|
||||||
|
res.status(200).json({ success: true, token });
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to process token renewal", { error });
|
||||||
|
res.status((error as Error).statusCode || 500).json({
|
||||||
|
success: false,
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWelcomeView(_req: Request, res: Response): Promise<void> {
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "Welcome to DTP Web Application",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSignUpForm(_req: Request, res: Response): Promise<void> {
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
form: "sign-up",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async getSignInForm(_req: Request, res: Response): Promise<void> {
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
form: "sign-in",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSignOut(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
if (req.session.token) {
|
||||||
|
try {
|
||||||
|
await SessionService.revokeJsonWebToken(req.session.token);
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to revoke JSON Web Token", { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.session.destroy((err: Error) => {
|
||||||
|
if (err) {
|
||||||
|
this.log.error("failed to destroy user session", { error: err });
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, message: "Signed out successfully" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuthController;
|
||||||
57
gadget-code/src/controllers/home.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// src/controllers/home.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
|
import { DtpController } from "../lib/controller.js";
|
||||||
|
|
||||||
|
export class HomeController extends DtpController {
|
||||||
|
get name(): string {
|
||||||
|
return "HomeController";
|
||||||
|
}
|
||||||
|
get slug(): string {
|
||||||
|
return "home";
|
||||||
|
}
|
||||||
|
get route(): string {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.router.get("/", this.getHome.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHome(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
authenticated: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
authenticated: true,
|
||||||
|
user: {
|
||||||
|
_id: req.user._id.toString(),
|
||||||
|
email: req.user.email,
|
||||||
|
displayName: req.user.displayName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to present the home page", { error });
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomeController;
|
||||||
75
gadget-code/src/controllers/lib/populators.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
// src/controllers/lib/populators.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import assert from "node:assert";
|
||||||
|
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
|
||||||
|
import { NextFunction, Request, RequestHandler, Response } from "express";
|
||||||
|
import { DtpController } from "../../lib/controller.ts";
|
||||||
|
|
||||||
|
import DroneService from "../../services/drone.ts";
|
||||||
|
import UserService from "../../services/user.ts";
|
||||||
|
|
||||||
|
export interface PopulateOptions {
|
||||||
|
requireObject?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function populateUserById(
|
||||||
|
controller: DtpController,
|
||||||
|
options?: PopulateOptions,
|
||||||
|
): RequestHandler {
|
||||||
|
options = Object.assign({ requireObject: true }, options);
|
||||||
|
return async function (
|
||||||
|
_req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
assert(userId, "User ID is required");
|
||||||
|
try {
|
||||||
|
const userIdObj = Types.ObjectId.createFromHexString(userId);
|
||||||
|
res.locals.userAccount = await UserService.getById(userIdObj);
|
||||||
|
if (options.requireObject && !res.locals.user) {
|
||||||
|
const error = new Error("User not found");
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
controller.log.error("failed to populate User by ID", {
|
||||||
|
userId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function populateDroneRegistrationById(
|
||||||
|
controller: DtpController,
|
||||||
|
options?: PopulateOptions,
|
||||||
|
): RequestHandler {
|
||||||
|
options = Object.assign({ requireObject: true }, options);
|
||||||
|
return async function (
|
||||||
|
_req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
registrationId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
assert(registrationId, "Drone registration ID is required");
|
||||||
|
try {
|
||||||
|
const registrationIdObj =
|
||||||
|
Types.ObjectId.createFromHexString(registrationId);
|
||||||
|
res.locals.registration = await DroneService.getById(registrationIdObj);
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
controller.log.error("failed to populate User by ID", {
|
||||||
|
registrationId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
84
gadget-code/src/controllers/user.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
// src/controllers/user.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
|
import UserService from "../services/user.js";
|
||||||
|
import { populateUserById } from "./lib/populators.js";
|
||||||
|
|
||||||
|
import { DtpController } from "../lib/controller.js";
|
||||||
|
|
||||||
|
export class UserController extends DtpController {
|
||||||
|
get name(): string {
|
||||||
|
return "UserController";
|
||||||
|
}
|
||||||
|
get slug(): string {
|
||||||
|
return "user";
|
||||||
|
}
|
||||||
|
get route(): string {
|
||||||
|
return "/user";
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
const requireAdmin = this.requireAdmin();
|
||||||
|
|
||||||
|
this.router.param("userId", populateUserById(this));
|
||||||
|
|
||||||
|
this.router.post("/:userId", requireAdmin, this.postUserUpdate.bind(this));
|
||||||
|
|
||||||
|
this.router.get("/:userId", requireAdmin, this.getUserView.bind(this));
|
||||||
|
this.router.get("/", requireAdmin, this.getUserHome.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async postUserUpdate(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await UserService.updateForUser(res.locals.userAccount, req.body);
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
user: res.locals.userAccount,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to update user", { error });
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserView(_req: Request, res: Response): Promise<void> {
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: "User profile view",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserHome(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const pagination = this.getPaginationParameters(req, 20);
|
||||||
|
|
||||||
|
const query = req.query.q ? req.query.q.toString() : undefined;
|
||||||
|
const recentUsers = await UserService.getRecent(pagination, query);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
users: recentUsers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.log.error("failed to present User Admin home", { error });
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserController;
|
||||||
34
gadget-code/src/lib/code-session.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// src/lib/code-session.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { Socket } from "socket.io";
|
||||||
|
import { SocketSession, SocketSessionType } from "./socket-session";
|
||||||
|
import { IUser } from "@/models/user";
|
||||||
|
|
||||||
|
export class CodeSession extends SocketSession {
|
||||||
|
protected type: SocketSessionType = SocketSessionType.Code;
|
||||||
|
|
||||||
|
constructor(socket: Socket, user: IUser) {
|
||||||
|
super(socket, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
register() {
|
||||||
|
super.register();
|
||||||
|
|
||||||
|
this.socket.on("thinking", this.onThinking.bind(this));
|
||||||
|
this.socket.on("response", this.onResponse.bind(this));
|
||||||
|
this.socket.on("tool-call", this.onToolCall.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async onThinking(): Promise<void> {}
|
||||||
|
|
||||||
|
async onResponse(): Promise<void> {}
|
||||||
|
|
||||||
|
async onToolCall(): Promise<void> {
|
||||||
|
this.log.info("tool call received", {
|
||||||
|
params: { thing: 1 },
|
||||||
|
response: "Woooo!",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
8
gadget-code/src/lib/component.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// src/lib/component.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
export interface DtpComponent {
|
||||||
|
get name(): string;
|
||||||
|
get slug(): string;
|
||||||
|
}
|
||||||
356
gadget-code/src/lib/controller.ts
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
// src/lib/controller.js
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import env from "../config/env.js";
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { rateLimit } from "express-rate-limit";
|
||||||
|
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import {
|
||||||
|
Router,
|
||||||
|
Request,
|
||||||
|
Response,
|
||||||
|
NextFunction,
|
||||||
|
RequestHandler,
|
||||||
|
} from "express";
|
||||||
|
|
||||||
|
import multer from "multer";
|
||||||
|
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
export interface CsrfTokenOptions {
|
||||||
|
name: string;
|
||||||
|
expiresMinutes: number;
|
||||||
|
allowReuse: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { ApiClientStatus } from "../models/api-client.js";
|
||||||
|
import { CsrfToken, ICsrfToken } from "../models/csrf-token.js";
|
||||||
|
import { IUser } from "../models/user.js";
|
||||||
|
|
||||||
|
import { DtpComponent } from "./component.js";
|
||||||
|
import { DtpPaginationParameters } from "./pagination-parameters.js";
|
||||||
|
import { DtpLog } from "./log.js";
|
||||||
|
|
||||||
|
import ApiClientService from "../services/api-client.js";
|
||||||
|
import CryptoService from "../services/crypto.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base class for all Web application controllers. A controller binds to an
|
||||||
|
* HTTP application REST route, and processes requests received on that route.
|
||||||
|
*
|
||||||
|
* This is usually accomplished by calling service methods congigured using
|
||||||
|
* request parameters, headers, and body content, then rendering HTML page or
|
||||||
|
* JSON object responses.
|
||||||
|
*/
|
||||||
|
export abstract class DtpController implements DtpComponent {
|
||||||
|
log: DtpLog;
|
||||||
|
router: Router;
|
||||||
|
|
||||||
|
abstract get name(): string;
|
||||||
|
abstract get slug(): string;
|
||||||
|
abstract get route(): string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.log = new DtpLog(this);
|
||||||
|
this.router = Router();
|
||||||
|
this.router.use(this.middleware.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract start(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware common to all controllers that populates the view model with
|
||||||
|
* some commonly expected values.
|
||||||
|
* @param _req Request The request being processed.
|
||||||
|
* @param res Response The response being generated.
|
||||||
|
* @param next NextFunction The next function to call when done.
|
||||||
|
*/
|
||||||
|
middleware(req: Request, res: Response, next: NextFunction) {
|
||||||
|
res.locals.request = req;
|
||||||
|
res.locals.currentView = this.slug;
|
||||||
|
res.locals.signupEnabled = env.user.signupEnabled;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
hmacMiddleware() {
|
||||||
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.rawBody) {
|
||||||
|
this.log.error("No raw body found");
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "No raw body found",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiClientId = req.headers["x-dtp-client-id"];
|
||||||
|
if (!apiClientId) {
|
||||||
|
this.log.error("API Client ID is required");
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "API Client ID is required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiClientIdObj = Types.ObjectId.createFromHexString(
|
||||||
|
apiClientId as string
|
||||||
|
);
|
||||||
|
const apiClient = await ApiClientService.getById(apiClientIdObj);
|
||||||
|
if (!apiClient) {
|
||||||
|
this.log.error("API client not found", { _id: apiClientId });
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "API Client not found",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiClient.status !== ApiClientStatus.Active) {
|
||||||
|
this.log.error("inactive API client", {
|
||||||
|
_id: apiClientId,
|
||||||
|
status: apiClient.status,
|
||||||
|
});
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "API client is not active",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hmacHeader = req.headers["x-dtp-hmac"];
|
||||||
|
if (!hmacHeader) {
|
||||||
|
this.log.error("No HMAC header found");
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "No HMAC header found",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hmacBody = CryptoService.createHmac(apiClient.secret, req.rawBody);
|
||||||
|
if (hmacHeader !== hmacBody) {
|
||||||
|
this.log.error("HMAC header does not match body", {
|
||||||
|
hmacHeader,
|
||||||
|
hmacBody,
|
||||||
|
});
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "HMAC header does not match body",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log.debug("HMAC header matches body");
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
requireUser(): RequestHandler {
|
||||||
|
return async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(403).json({ success: false, message: "Authentication required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
requireAdmin(): RequestHandler {
|
||||||
|
return async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
const user: IUser | null | undefined = req.user;
|
||||||
|
if (!user || !user.flags.isAdmin) {
|
||||||
|
res.status(403).json({ success: false, message: "Admin access required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadChild(filename: string): Promise<DtpController> {
|
||||||
|
const pathObj = path.parse(filename);
|
||||||
|
this.log.info("loading child controller", {
|
||||||
|
script: pathObj.name,
|
||||||
|
path: filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ControllerClass = (await import(filename)).default;
|
||||||
|
if (!ControllerClass) {
|
||||||
|
this.log.error(
|
||||||
|
"failed to receive a default export class from child controller",
|
||||||
|
{
|
||||||
|
script: pathObj.name,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
throw new Error("Child controller failed to provide a default export");
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller: DtpController = new ControllerClass(ControllerClass);
|
||||||
|
|
||||||
|
this.log.info("starting child controller", {
|
||||||
|
name: ControllerClass.name,
|
||||||
|
});
|
||||||
|
await controller.start();
|
||||||
|
|
||||||
|
const childRoute = this.route + controller.route;
|
||||||
|
this.log.info("mounting child controller", {
|
||||||
|
name: ControllerClass.name,
|
||||||
|
childRoute,
|
||||||
|
});
|
||||||
|
this.router.use(controller.route, controller.router);
|
||||||
|
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves set pagination parameters from the request.
|
||||||
|
* @param req Request The request being processed.
|
||||||
|
* @param maxPerPage number The maximum number of records to display per page.
|
||||||
|
* @param pageParamName string The name of the page index parameter.
|
||||||
|
* @param cppParamName string The name of the count-per-page parameter.
|
||||||
|
* @returns A new DtpPaginationParameters instance containing pagination
|
||||||
|
* parameter values.
|
||||||
|
*/
|
||||||
|
getPaginationParameters(
|
||||||
|
req: Request,
|
||||||
|
maxPerPage: number,
|
||||||
|
pageParamName: string = "p",
|
||||||
|
cppParamName: string = "cpp"
|
||||||
|
): DtpPaginationParameters {
|
||||||
|
const pageParam: string = req.query[pageParamName]
|
||||||
|
? (req.query[pageParamName] as string)
|
||||||
|
: "1";
|
||||||
|
const cppParam: string = req.query[cppParamName]
|
||||||
|
? (req.query[cppParamName] as string)
|
||||||
|
: maxPerPage.toString();
|
||||||
|
const pagination: DtpPaginationParameters = {
|
||||||
|
p: parseInt(pageParam, 10),
|
||||||
|
skip: 0,
|
||||||
|
cpp: parseInt(cppParam, 10),
|
||||||
|
};
|
||||||
|
if (pagination.p < 1) {
|
||||||
|
pagination.p = 1;
|
||||||
|
}
|
||||||
|
if (pagination.cpp > maxPerPage) {
|
||||||
|
pagination.cpp = maxPerPage;
|
||||||
|
}
|
||||||
|
pagination.skip = (pagination.p - 1) * pagination.cpp;
|
||||||
|
return pagination;
|
||||||
|
}
|
||||||
|
|
||||||
|
createLimiter(
|
||||||
|
seconds: number,
|
||||||
|
limit: number,
|
||||||
|
message: string,
|
||||||
|
keyGenerator?: (req: Request, res: Response) => string
|
||||||
|
): RequestHandler {
|
||||||
|
return rateLimit({
|
||||||
|
windowMs: seconds * 1000,
|
||||||
|
limit,
|
||||||
|
message: message || "Too many requests. Please try again later.",
|
||||||
|
standardHeaders: "draft-8",
|
||||||
|
legacyHeaders: false,
|
||||||
|
statusCode: 429,
|
||||||
|
keyGenerator,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a `multipart/form-encoded` HTTP POST processor using the
|
||||||
|
* [multer](https://www.npmjs.com/package/multer) estension.
|
||||||
|
* @param slug string The path slug into which files will be stored.
|
||||||
|
* @param options multer.Options Options for the form processor.
|
||||||
|
* @returns An ExpressJS middleware that enables a route to receive files.
|
||||||
|
*/
|
||||||
|
createMulter(slug: string, options: multer.Options): multer.Multer {
|
||||||
|
if (!!slug && typeof slug === "object") {
|
||||||
|
options = slug;
|
||||||
|
slug = this.slug;
|
||||||
|
} else {
|
||||||
|
slug = slug || this.slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
options = Object.assign(
|
||||||
|
{
|
||||||
|
dest: path.join(env.https.uploadPath, slug),
|
||||||
|
},
|
||||||
|
options || {}
|
||||||
|
);
|
||||||
|
|
||||||
|
return multer(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a CSRF token used in forms to help prevent XSS attacks.
|
||||||
|
* @param req Request The request for which a CSRF token will be generated.
|
||||||
|
* @param options CsrfTokenOptions The options to be used when creating the
|
||||||
|
* token.
|
||||||
|
* @returns The new CsrfToken for use.
|
||||||
|
*/
|
||||||
|
async createCsrfToken(
|
||||||
|
req: Request,
|
||||||
|
options: CsrfTokenOptions
|
||||||
|
): Promise<ICsrfToken> {
|
||||||
|
const NOW = new Date();
|
||||||
|
|
||||||
|
options = Object.assign(
|
||||||
|
{
|
||||||
|
expiresMinutes: 30,
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options.expiresMinutes > 120) {
|
||||||
|
const e = new Error("CSRF tokens have a max lifespan of 120 minutes");
|
||||||
|
e.statusCode = 400;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = new CsrfToken();
|
||||||
|
token.created = NOW;
|
||||||
|
token.expires = dayjs(NOW).add(options.expiresMinutes, "minute").toDate();
|
||||||
|
if (req.user) {
|
||||||
|
token.user = req.user._id;
|
||||||
|
}
|
||||||
|
if (req.ip) {
|
||||||
|
token.ip = req.ip;
|
||||||
|
}
|
||||||
|
token.token = uuidv4();
|
||||||
|
await token.save();
|
||||||
|
|
||||||
|
const tokenObj = token.toObject();
|
||||||
|
tokenObj.name = `csrf-token-${options.name}`;
|
||||||
|
|
||||||
|
return tokenObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force-save the current user session and ensure it's written before
|
||||||
|
* proceeding.
|
||||||
|
* @param req Express.Request The request being processed.
|
||||||
|
* @returns A promise that resolves when the session is saved.
|
||||||
|
*/
|
||||||
|
async saveSession(req: Request): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
req.session.save((err) => {
|
||||||
|
if (err) {
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
15
gadget-code/src/lib/db.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// src/lib/db.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
|
||||||
|
import { DtpLog } from "./log.js";
|
||||||
|
const log = new DtpLog({ name: "db", slug: "db" });
|
||||||
|
|
||||||
|
const DB_URL = `mongodb://${process.env.DTP_MONGODB_HOST}/${process.env.DTP_MONGODB_DATABASE}`;
|
||||||
|
|
||||||
|
log.info("connecting to MongoDB", DB_URL);
|
||||||
|
export const db = mongoose.connect(DB_URL);
|
||||||
|
|
||||||
|
export default db;
|
||||||
22
gadget-code/src/lib/drone-session.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// src/lib/drone-session.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { IDroneRegistration } from "@/models/drone-registration";
|
||||||
|
import { SocketSession, SocketSessionType } from "./socket-session";
|
||||||
|
import { Socket } from "socket.io";
|
||||||
|
import { IUser } from "@/models/user";
|
||||||
|
|
||||||
|
export class DroneSession extends SocketSession {
|
||||||
|
protected type: SocketSessionType = SocketSessionType.Drone;
|
||||||
|
registration: IDroneRegistration;
|
||||||
|
|
||||||
|
constructor(socket: Socket, registration: IDroneRegistration) {
|
||||||
|
super(socket, registration.user as IUser);
|
||||||
|
this.registration = registration;
|
||||||
|
}
|
||||||
|
|
||||||
|
register() {
|
||||||
|
super.register();
|
||||||
|
}
|
||||||
|
}
|
||||||
60
gadget-code/src/lib/log-file.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// src/lib/log-file.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import numeral from "numeral";
|
||||||
|
|
||||||
|
import { Writable, WritableOptions } from "stream";
|
||||||
|
|
||||||
|
type StreamCallback = (error?: Error | null) => void;
|
||||||
|
|
||||||
|
export interface DtpLogFileOptions extends WritableOptions {
|
||||||
|
basePath: string;
|
||||||
|
name: string;
|
||||||
|
maxWritesPerFile: number;
|
||||||
|
maxFiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DtpLogFile extends Writable {
|
||||||
|
options: DtpLogFileOptions;
|
||||||
|
file?: fs.WriteStream;
|
||||||
|
|
||||||
|
fileIdx: number = 0;
|
||||||
|
writeCount: number = 0;
|
||||||
|
|
||||||
|
constructor(options: DtpLogFileOptions) {
|
||||||
|
super(options);
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
open(): void {
|
||||||
|
fs.mkdirSync(this.options.basePath, { recursive: true });
|
||||||
|
const filename = path.join(
|
||||||
|
this.options.basePath,
|
||||||
|
`${this.options.name}.${numeral(this.fileIdx).format("000")}.log`
|
||||||
|
);
|
||||||
|
this.file = fs.createWriteStream(filename, { encoding: "utf-8" });
|
||||||
|
this.writeCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_write(
|
||||||
|
chunk: unknown,
|
||||||
|
encoding: BufferEncoding,
|
||||||
|
callback: StreamCallback
|
||||||
|
): boolean {
|
||||||
|
if (!this.file) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.writeCount > this.options.maxWritesPerFile) {
|
||||||
|
this.file.close();
|
||||||
|
if (++this.fileIdx > this.options.maxFiles) {
|
||||||
|
this.fileIdx = 0;
|
||||||
|
}
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
return this.file.write(chunk, encoding, callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
gadget-code/src/lib/log-transport-console.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// src/lib/log-transport-console.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import * as util from "node:util";
|
||||||
|
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import color from "ansicolor";
|
||||||
|
|
||||||
|
import { DtpLogLevel } from "./log.js";
|
||||||
|
import { DtpLogTransport } from "./log-transport.js";
|
||||||
|
import { DtpComponent } from "./component.ts";
|
||||||
|
|
||||||
|
export class DtpLogTransportConsole implements DtpLogTransport {
|
||||||
|
async writeLog(
|
||||||
|
timestamp: Date,
|
||||||
|
component: DtpComponent,
|
||||||
|
level: DtpLogLevel,
|
||||||
|
message: string,
|
||||||
|
metadata?: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
let clevel = level.padEnd(5);
|
||||||
|
switch (level) {
|
||||||
|
case "debug":
|
||||||
|
clevel = color.darkGray(clevel);
|
||||||
|
break;
|
||||||
|
case "info":
|
||||||
|
clevel = color.green(clevel);
|
||||||
|
break;
|
||||||
|
case "warn":
|
||||||
|
clevel = color.yellow(clevel);
|
||||||
|
break;
|
||||||
|
case "alert":
|
||||||
|
clevel = color.red(clevel);
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
clevel = color.bgRed.white(clevel);
|
||||||
|
break;
|
||||||
|
case "crit":
|
||||||
|
clevel = color.bgRed.yellow(clevel);
|
||||||
|
break;
|
||||||
|
case "fatal":
|
||||||
|
clevel = color.bgRed.darkGray(clevel);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ccomponent = color.cyan(component.name);
|
||||||
|
const ctimestamp = color.darkGray(
|
||||||
|
dayjs(timestamp).format("YYYY-MM-DD HH:mm:ss.SSS")
|
||||||
|
);
|
||||||
|
const cmessage = color.darkGray(message);
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
|
console.log(
|
||||||
|
`${ctimestamp} ${clevel} ${ccomponent} ${cmessage}`,
|
||||||
|
util.inspect(metadata, false, Infinity, true)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(`${ctimestamp} ${clevel} ${ccomponent} ${cmessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
gadget-code/src/lib/log-transport-file.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// src/lib/log-transport-file.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { Writable } from "node:stream";
|
||||||
|
|
||||||
|
import { DtpLogLevel } from "./log.js";
|
||||||
|
import { DtpLogTransport } from "./log-transport.js";
|
||||||
|
import { DtpComponent } from "./component.ts";
|
||||||
|
|
||||||
|
export class DtpLogTransportFile implements DtpLogTransport {
|
||||||
|
file: Writable;
|
||||||
|
|
||||||
|
constructor(file: Writable) {
|
||||||
|
this.file = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeLog(
|
||||||
|
timestamp: Date,
|
||||||
|
component: DtpComponent,
|
||||||
|
level: DtpLogLevel,
|
||||||
|
message: string,
|
||||||
|
metadata?: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const logMessage = JSON.stringify({
|
||||||
|
timestamp,
|
||||||
|
component,
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
const chunk = Buffer.from(logMessage + "\r\n");
|
||||||
|
this.file.write(chunk, (error: Error | null | undefined): void => {
|
||||||
|
if (error) {
|
||||||
|
return reject(error);
|
||||||
|
}
|
||||||
|
return resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
16
gadget-code/src/lib/log-transport.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// src/lib/log-transport.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { DtpComponent } from "./component.ts";
|
||||||
|
import { DtpLogLevel } from "./log.js";
|
||||||
|
|
||||||
|
export abstract class DtpLogTransport {
|
||||||
|
abstract writeLog(
|
||||||
|
timestamp: Date,
|
||||||
|
component: DtpComponent,
|
||||||
|
level: DtpLogLevel,
|
||||||
|
message: string,
|
||||||
|
metadata?: unknown
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
81
gadget-code/src/lib/log.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
// src/lib/log.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import env from "../config/env.js";
|
||||||
|
|
||||||
|
import { DtpComponent } from "./component.ts";
|
||||||
|
import { DtpLogTransportConsole } from "./log-transport-console.js";
|
||||||
|
import { DtpLogTransportFile } from "./log-transport-file.js";
|
||||||
|
import { DtpLogTransport } from "./log-transport.js";
|
||||||
|
import { DtpLogFile } from "./log-file.js";
|
||||||
|
|
||||||
|
export enum DtpLogLevel {
|
||||||
|
debug = "debug",
|
||||||
|
info = "info",
|
||||||
|
warn = "warn",
|
||||||
|
alert = "alert",
|
||||||
|
error = "error",
|
||||||
|
crit = "crit",
|
||||||
|
fatal = "fatal",
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DtpLog {
|
||||||
|
component: DtpComponent;
|
||||||
|
transports: Array<DtpLogTransport> = [];
|
||||||
|
|
||||||
|
constructor(component: DtpComponent, file?: DtpLogFile) {
|
||||||
|
this.component = component;
|
||||||
|
|
||||||
|
if (env.log.console.enabled) {
|
||||||
|
this.transports.push(new DtpLogTransportConsole());
|
||||||
|
}
|
||||||
|
if (env.log.file.enabled && file) {
|
||||||
|
this.transports.push(new DtpLogTransportFile(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async debug(message: string, metadata?: unknown) {
|
||||||
|
if (!env.log.levels.debug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return this.writeLog(DtpLogLevel.debug, message, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
async info(message: string, metadata?: unknown) {
|
||||||
|
if (!env.log.levels.info) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return this.writeLog(DtpLogLevel.info, message, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
async warn(message: string, metadata?: unknown) {
|
||||||
|
if (!env.log.levels.warn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return this.writeLog(DtpLogLevel.warn, message, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
async alert(message: string, metadata?: unknown) {
|
||||||
|
return this.writeLog(DtpLogLevel.alert, message, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
async error(message: string, metadata?: unknown) {
|
||||||
|
return this.writeLog(DtpLogLevel.error, message, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
async crit(message: string, metadata?: unknown) {
|
||||||
|
return this.writeLog(DtpLogLevel.crit, message, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fatal(message: string, metadata?: unknown) {
|
||||||
|
this.writeLog(DtpLogLevel.fatal, message, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeLog(level: DtpLogLevel, message: string, metadata?: unknown) {
|
||||||
|
const NOW = new Date();
|
||||||
|
for (const transport of this.transports) {
|
||||||
|
transport.writeLog(NOW, this.component, level, message, metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
gadget-code/src/lib/ollama.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
// src/lib/ollama.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
export { createAiApi } from "@gadget/ai";
|
||||||
9
gadget-code/src/lib/pagination-parameters.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// src/lib/pagination-parameters.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
export class DtpPaginationParameters {
|
||||||
|
p: number = 1; // page number
|
||||||
|
cpp: number = 20; // count per page
|
||||||
|
skip: number = 0;
|
||||||
|
}
|
||||||
17
gadget-code/src/lib/process.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// src/lib/process.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { DtpComponent } from "./component.js";
|
||||||
|
import { DtpLog } from "./log.js";
|
||||||
|
|
||||||
|
export abstract class DtpProcess implements DtpComponent {
|
||||||
|
log: DtpLog;
|
||||||
|
|
||||||
|
abstract get name(): string;
|
||||||
|
abstract get slug(): string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.log = new DtpLog(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
gadget-code/src/lib/redis.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// src/lib/redis.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import env from "../config/env.js";
|
||||||
|
import { Redis } from "ioredis";
|
||||||
|
|
||||||
|
import { DtpLog } from "./log.js";
|
||||||
|
const log = new DtpLog({ name: "redis", slug: "redis" });
|
||||||
|
|
||||||
|
log.info("connecting to Redis", {
|
||||||
|
host: env.redis.host,
|
||||||
|
port: env.redis.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
const redis = new Redis(env.redis);
|
||||||
|
redis.setMaxListeners(64);
|
||||||
|
|
||||||
|
export default redis;
|
||||||
22
gadget-code/src/lib/service.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// src/lib/service.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
// import env from "../config/env.js";
|
||||||
|
|
||||||
|
import { DtpComponent } from "./component.js";
|
||||||
|
import { DtpLog } from "./log.js";
|
||||||
|
|
||||||
|
export abstract class DtpService implements DtpComponent {
|
||||||
|
log: DtpLog;
|
||||||
|
|
||||||
|
abstract get name(): string;
|
||||||
|
abstract get slug(): string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.log = new DtpLog(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract start(): Promise<void>;
|
||||||
|
abstract stop(): Promise<void>;
|
||||||
|
}
|
||||||
48
gadget-code/src/lib/socket-session.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
// src/lib/socket-session.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { Socket } from "socket.io";
|
||||||
|
import { IUser } from "@/models/user";
|
||||||
|
import { DtpLog } from "./log";
|
||||||
|
|
||||||
|
export enum SocketSessionType {
|
||||||
|
Code = "code",
|
||||||
|
Drone = "drone",
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class SocketSession {
|
||||||
|
protected log: DtpLog;
|
||||||
|
protected socket: Socket;
|
||||||
|
protected _user: IUser;
|
||||||
|
|
||||||
|
get user() {
|
||||||
|
return this._user;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract type: SocketSessionType;
|
||||||
|
|
||||||
|
constructor(socket: Socket, user: IUser) {
|
||||||
|
this.log = new DtpLog({
|
||||||
|
name: "SocketSession",
|
||||||
|
slug: "lib:socket-session",
|
||||||
|
});
|
||||||
|
this.socket = socket;
|
||||||
|
this._user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers socket event and message handlers intended to be processed by all
|
||||||
|
* socket sessions of all types.
|
||||||
|
*/
|
||||||
|
register() {
|
||||||
|
this.socket.on("disconnect", this.onSocketDisconnect.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSocketDisconnect(): Promise<void> {
|
||||||
|
this.log.info("socket disconnected", {
|
||||||
|
id: this.socket.id,
|
||||||
|
user: this.user,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
42
gadget-code/src/lib/validators.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// src/lib/validators.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if a given string is a valid Firebase user ID.
|
||||||
|
*
|
||||||
|
* A valid Firebase user ID must meet the following criteria:
|
||||||
|
* 1. Must be between 6 and 50 characters long (inclusive)
|
||||||
|
* 2. Can only contain alphanumeric characters, underscore (_), dot (.),
|
||||||
|
* hyphen (-), plus (+), and slash (/) characters
|
||||||
|
* 3. Cannot start or end with a special character
|
||||||
|
*
|
||||||
|
* @param {string} userId - The string to validate as a Firebase user ID
|
||||||
|
* @returns {boolean} True if the string is a valid Firebase user ID, false
|
||||||
|
* otherwise
|
||||||
|
*/
|
||||||
|
export function isValidFirebaseUserId(userId: string): boolean {
|
||||||
|
// Check length requirements
|
||||||
|
const minLength = 6;
|
||||||
|
const maxLength = 50;
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof userId !== "string" ||
|
||||||
|
userId.length < minLength ||
|
||||||
|
userId.length > maxLength
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define allowed special characters
|
||||||
|
const specialChars = ["_", ".", "-", "+", "/"];
|
||||||
|
|
||||||
|
// Regular expression to match the Firebase user ID pattern
|
||||||
|
const regex = new RegExp(
|
||||||
|
`^[A-Za-z0-9]+(${specialChars
|
||||||
|
.map((c) => `\\${c}`)
|
||||||
|
.join("|")}[A-Za-z0-9]*)*$`
|
||||||
|
);
|
||||||
|
|
||||||
|
return regex.test(userId);
|
||||||
|
}
|
||||||
32
gadget-code/src/lib/worker.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
// src/lib/controller.js
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import env from "../config/env.js";
|
||||||
|
import BullQueue from "bull";
|
||||||
|
|
||||||
|
import { DtpLog } from "./log.js";
|
||||||
|
import { DtpComponent } from "./component.js";
|
||||||
|
|
||||||
|
export abstract class DtpWorker implements DtpComponent {
|
||||||
|
log: DtpLog;
|
||||||
|
jobQueue: BullQueue.Queue | undefined;
|
||||||
|
|
||||||
|
abstract get name(): string;
|
||||||
|
abstract get slug(): string;
|
||||||
|
abstract get queueName(): string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.log = new DtpLog(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
this.log.info("Starting worker");
|
||||||
|
this.log.info("connecting to Bull queue on Redis", {
|
||||||
|
queueName: this.queueName,
|
||||||
|
});
|
||||||
|
this.jobQueue = new BullQueue(this.queueName, { redis: env.redis });
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract stop(): Promise<void>;
|
||||||
|
}
|
||||||
124
gadget-code/src/models/ai-provider.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
// src/models/ai-provider.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { Schema, Document, model } from "mongoose";
|
||||||
|
|
||||||
|
export type AiApiType = "ollama" | "openai";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalised capability flags stored with each model record. These are
|
||||||
|
* populated during model refresh from provider-specific metadata and drive
|
||||||
|
* slot-based filtering in the UI (e.g. only canCallTools models for Agent).
|
||||||
|
*/
|
||||||
|
export interface IAiModelSettings {
|
||||||
|
temperature?: number;
|
||||||
|
topP?: number;
|
||||||
|
topK?: number;
|
||||||
|
numCtx?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAiModelCapabilities {
|
||||||
|
/** Model supports structured function / tool calling via the API. */
|
||||||
|
canCallTools: boolean;
|
||||||
|
/** Model accepts image inputs (multimodal / vision). */
|
||||||
|
hasVision: boolean;
|
||||||
|
/** Model can produce vector embeddings (required for the Vector slot). */
|
||||||
|
hasEmbedding: boolean;
|
||||||
|
/** Model has an explicit reasoning / thinking phase (e.g. o1, QwQ). */
|
||||||
|
hasThinking: boolean;
|
||||||
|
/** Model is instruction-tuned / chat-tuned (as opposed to a base model). */
|
||||||
|
isInstructTuned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AiModelCapabilitiesSchema = new Schema<IAiModelCapabilities>(
|
||||||
|
{
|
||||||
|
canCallTools: { type: Boolean, default: false },
|
||||||
|
hasVision: { type: Boolean, default: false },
|
||||||
|
hasEmbedding: { type: Boolean, default: false },
|
||||||
|
hasThinking: { type: Boolean, default: false },
|
||||||
|
isInstructTuned: { type: Boolean, default: false },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface IAiModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Raw parameter count in billions (float). Use parameterLabel for display.
|
||||||
|
*/
|
||||||
|
parameterCount?: number;
|
||||||
|
/**
|
||||||
|
* Human-readable parameter size label sourced directly from the provider,
|
||||||
|
* e.g. "7b", "70b", "3.8b".
|
||||||
|
*/
|
||||||
|
parameterLabel?: string;
|
||||||
|
contextWindow?: number;
|
||||||
|
capabilities: IAiModelCapabilities;
|
||||||
|
settings?: IAiModelSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AiModelSettingsSchema = new Schema<IAiModelSettings>(
|
||||||
|
{
|
||||||
|
temperature: { type: Number },
|
||||||
|
topP: { type: Number },
|
||||||
|
topK: { type: Number },
|
||||||
|
numCtx: { type: Number },
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AiModelSchema = new Schema<IAiModel>(
|
||||||
|
{
|
||||||
|
id: { type: String, required: true },
|
||||||
|
name: { type: String, required: true },
|
||||||
|
parameterCount: { type: Number },
|
||||||
|
parameterLabel: { type: String },
|
||||||
|
contextWindow: { type: Number },
|
||||||
|
capabilities: {
|
||||||
|
type: AiModelCapabilitiesSchema,
|
||||||
|
default: () => ({
|
||||||
|
canCallTools: false,
|
||||||
|
hasVision: false,
|
||||||
|
hasEmbedding: false,
|
||||||
|
hasThinking: false,
|
||||||
|
isInstructTuned: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
type: AiModelSettingsSchema,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ _id: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface IAiProvider extends Document {
|
||||||
|
name: string;
|
||||||
|
apiType: AiApiType;
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
enabled: boolean;
|
||||||
|
models: IAiModel[];
|
||||||
|
lastModelRefresh: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AiProviderSchema = new Schema<IAiProvider>({
|
||||||
|
name: { type: String, required: true },
|
||||||
|
apiType: { type: String, enum: ["ollama", "openai"], required: true },
|
||||||
|
baseUrl: { type: String, required: true },
|
||||||
|
apiKey: { type: String, required: true, select: false },
|
||||||
|
enabled: { type: Boolean, default: true, required: true },
|
||||||
|
models: { type: [AiModelSchema], default: [], required: true },
|
||||||
|
lastModelRefresh: { type: Date, default: Date.now },
|
||||||
|
});
|
||||||
|
|
||||||
|
AiProviderSchema.index({ name: 1 }, { unique: true });
|
||||||
|
|
||||||
|
export const AiProvider = model<IAiProvider>("AiProvider", AiProviderSchema);
|
||||||
|
export default AiProvider;
|
||||||
|
|
||||||
|
// Note: Index synchronization is now handled during application startup
|
||||||
|
// to ensure the database connection is established first.
|
||||||
|
// See src/lib/db.ts for the syncDatabaseIndexes function.
|
||||||
43
gadget-code/src/models/api-client-log.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
// src/models/api-client-log.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { Types, Schema, Document, model } from "mongoose";
|
||||||
|
|
||||||
|
import { DtpLog } from "../lib/log.js";
|
||||||
|
import { IApiClient } from "./api-client.js";
|
||||||
|
const log = new DtpLog({
|
||||||
|
name: "ApiClientModel",
|
||||||
|
slug: "apiClient",
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface IApiClientLog extends Document {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
client: IApiClient | Types.ObjectId;
|
||||||
|
createdAt: Date;
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
export const ApiClientLogSchema = new Schema<IApiClientLog>({
|
||||||
|
client: { type: Types.ObjectId, required: true, index: 1, ref: "ApiClient" },
|
||||||
|
createdAt: {
|
||||||
|
type: Date,
|
||||||
|
default: Date.now,
|
||||||
|
required: true,
|
||||||
|
index: -1,
|
||||||
|
expires: "30d",
|
||||||
|
},
|
||||||
|
method: { type: String, required: true },
|
||||||
|
url: { type: String, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ApiClientLog = model<IApiClientLog>(
|
||||||
|
"ApiClientLog",
|
||||||
|
ApiClientLogSchema
|
||||||
|
);
|
||||||
|
export default ApiClientLog;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
log.info("Syncing indexes...");
|
||||||
|
await ApiClientLog.syncIndexes();
|
||||||
|
})();
|
||||||
49
gadget-code/src/models/api-client.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// src/models/api-client.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { Types, Schema, Document, model } from "mongoose";
|
||||||
|
|
||||||
|
import { DtpLog } from "../lib/log.js";
|
||||||
|
const log = new DtpLog({
|
||||||
|
name: "ApiClientModel",
|
||||||
|
slug: "apiClient",
|
||||||
|
});
|
||||||
|
|
||||||
|
export enum ApiClientStatus {
|
||||||
|
Active = "active",
|
||||||
|
Inactive = "inactive",
|
||||||
|
Archived = "archived",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IApiClient extends Document {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
status: ApiClientStatus;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
const ApiClientSchema = new Schema<IApiClient>({
|
||||||
|
createdAt: { type: Date, required: true },
|
||||||
|
updatedAt: { type: Date, required: true },
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ApiClientStatus,
|
||||||
|
default: ApiClientStatus.Active,
|
||||||
|
required: true,
|
||||||
|
index: 1,
|
||||||
|
},
|
||||||
|
name: { type: String, required: true },
|
||||||
|
description: { type: String },
|
||||||
|
secret: { type: String, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ApiClient = model<IApiClient>("ApiClient", ApiClientSchema);
|
||||||
|
export default ApiClient;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
log.info("Syncing indexes...");
|
||||||
|
await ApiClient.syncIndexes();
|
||||||
|
})();
|
||||||
66
gadget-code/src/models/chat-session.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// src/models/chat-session.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { Types, Schema, Document, model } from "mongoose";
|
||||||
|
|
||||||
|
import { IUser } from "./user.js";
|
||||||
|
import { IProject } from "./project.js";
|
||||||
|
|
||||||
|
export enum ChatSessionMode {
|
||||||
|
Plan = "plan", // for planning and brainstorming
|
||||||
|
Build = "build", // for building and coding
|
||||||
|
Test = "test", // for testing and debugging
|
||||||
|
Ship = "ship", // for finalizing and shipping
|
||||||
|
Develop = "dev", // for working on the Gadget Code harness itself
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IChatSessionPin {
|
||||||
|
_id?: Types.ObjectId;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
export const ChatSessionPinSchema = new Schema<IChatSessionPin>({
|
||||||
|
content: { type: String, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface IChatSession extends Document {
|
||||||
|
createdAt: Date;
|
||||||
|
lastMessageAt?: Date;
|
||||||
|
user: IUser | Types.ObjectId;
|
||||||
|
project: IProject | Types.ObjectId;
|
||||||
|
name: string;
|
||||||
|
mode: ChatSessionMode;
|
||||||
|
stats: {
|
||||||
|
turnCount: number;
|
||||||
|
toolCallCount: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
};
|
||||||
|
pins: IChatSessionPin[];
|
||||||
|
}
|
||||||
|
export const ChatSessionSchema = new Schema<IChatSession>({
|
||||||
|
createdAt: { type: Date, default: Date.now, required: true },
|
||||||
|
lastMessageAt: { type: Date },
|
||||||
|
user: { type: Types.ObjectId, required: true, index: 1, ref: "User" },
|
||||||
|
project: { type: Types.ObjectId, required: false, index: 1, ref: "Project" },
|
||||||
|
name: { type: String, default: "New Session", required: true },
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
enum: ChatSessionMode,
|
||||||
|
default: ChatSessionMode.Build,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
turnCount: { type: Number, default: 0, required: true },
|
||||||
|
toolCallCount: { type: Number, default: 0, required: true },
|
||||||
|
inputTokens: { type: Number, default: 0, required: true },
|
||||||
|
outputTokens: { type: Number, default: 0, required: true },
|
||||||
|
},
|
||||||
|
pins: { type: [ChatSessionPinSchema], default: [], required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ChatSession = model<IChatSession>(
|
||||||
|
"ChatSession",
|
||||||
|
ChatSessionSchema,
|
||||||
|
);
|
||||||
|
export default ChatSession;
|
||||||
146
gadget-code/src/models/chat-turn.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
// src/models/chat-turn.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { Types, Schema, Document, model } from "mongoose";
|
||||||
|
|
||||||
|
import { IUser } from "./user.js";
|
||||||
|
import { IProject } from "./project.js";
|
||||||
|
import { ChatSessionMode, IChatSession } from "./chat-session.js";
|
||||||
|
import { IAiProvider } from "./ai-provider.js";
|
||||||
|
|
||||||
|
export enum ChatTurnStatus {
|
||||||
|
Processing = "processing",
|
||||||
|
Finished = "finished",
|
||||||
|
Error = "error",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IChatTurnStats {
|
||||||
|
toolCallCount: number; // total number of tool functions called this turn
|
||||||
|
inputTokens: number; // total number of input tokens processed this turn
|
||||||
|
thinkingTokenCount: number; // total number of thinking tokens generated this turn
|
||||||
|
responseTokens: number; // total number of response/output tokens generated this turn
|
||||||
|
durationMs: number; // total turn runtime in seconds
|
||||||
|
durationLabel: string; // total turn runtime as hh:mm:ss
|
||||||
|
}
|
||||||
|
export const ChatTurnStatsSchema = new Schema<IChatTurnStats>({
|
||||||
|
toolCallCount: { type: Number, default: 0, required: true },
|
||||||
|
inputTokens: { type: Number, default: 0, required: true },
|
||||||
|
thinkingTokenCount: { type: Number, default: 0, required: true },
|
||||||
|
responseTokens: { type: Number, default: 0, required: true },
|
||||||
|
durationMs: { type: Number, default: 0, required: true },
|
||||||
|
durationLabel: { type: String, default: "pending", required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface IChatToolCall {
|
||||||
|
name: string; // tool function name being called
|
||||||
|
parameters: string; // JSON.stringify of input parameters
|
||||||
|
response: string; // the tool's response
|
||||||
|
}
|
||||||
|
export const ChatToolCallSchema = new Schema<IChatToolCall>({
|
||||||
|
name: { type: String, required: true },
|
||||||
|
parameters: { type: String, required: false },
|
||||||
|
response: { type: String, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface IChatSubagentProcess {
|
||||||
|
prompt: string;
|
||||||
|
thinking?: string;
|
||||||
|
response: string;
|
||||||
|
toolCalls: IChatToolCall[];
|
||||||
|
stats: IChatTurnStats;
|
||||||
|
}
|
||||||
|
export const ChatSubagentProcessSchema = new Schema<IChatSubagentProcess>({
|
||||||
|
prompt: { type: String, required: true },
|
||||||
|
thinking: { type: String, required: false },
|
||||||
|
response: { type: String, required: false },
|
||||||
|
toolCalls: { type: [ChatToolCallSchema], default: [], required: true },
|
||||||
|
stats: { type: ChatTurnStatsSchema, default: {}, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chat turn is a single prompt/response pair with tool call accounting. It
|
||||||
|
* stores all data generated by one run of the Agentic Workflow Loop by a Gadget
|
||||||
|
* Drone process.
|
||||||
|
*/
|
||||||
|
export interface IChatTurn extends Document {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
createdAt: Date;
|
||||||
|
user: IUser | Types.ObjectId;
|
||||||
|
project: IProject | Types.ObjectId;
|
||||||
|
session: IChatSession | Types.ObjectId;
|
||||||
|
provider: IAiProvider | Types.ObjectId;
|
||||||
|
llm: string; // id/name of the model used to process the prompt
|
||||||
|
mode: ChatSessionMode; // session mode for this turn/prompt
|
||||||
|
status: ChatTurnStatus;
|
||||||
|
prompt: string;
|
||||||
|
thinking?: string;
|
||||||
|
response?: string;
|
||||||
|
toolCalls: IChatToolCall[];
|
||||||
|
subagents: IChatSubagentProcess[]; // subagents used while processing this turn
|
||||||
|
stats: IChatTurnStats;
|
||||||
|
}
|
||||||
|
export const ChatTurnSchema = new Schema<IChatTurn>({
|
||||||
|
createdAt: { type: Date, default: Date.now, required: true },
|
||||||
|
user: { type: Types.ObjectId, required: true, ref: "User" },
|
||||||
|
project: { type: Types.ObjectId, required: false, ref: "Project" },
|
||||||
|
session: { type: Types.ObjectId, required: true, ref: "ChatSession" },
|
||||||
|
provider: { type: Types.ObjectId, required: true, ref: "AiProvider" },
|
||||||
|
llm: { type: String, required: true }, // id/name of the model used to process the prompt
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
enum: ChatSessionMode,
|
||||||
|
default: ChatSessionMode.Build,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: ChatTurnStatus,
|
||||||
|
default: ChatTurnStatus.Processing,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
prompt: { type: String, required: true },
|
||||||
|
thinking: { type: String, required: false },
|
||||||
|
response: { type: String, required: false },
|
||||||
|
toolCalls: { type: [ChatToolCallSchema], default: [], required: true },
|
||||||
|
subagents: { type: [ChatSubagentProcessSchema], default: [], required: true },
|
||||||
|
stats: {
|
||||||
|
toolCallCount: { type: Number, default: 0, required: true },
|
||||||
|
inputTokens: { type: Number, default: 0, required: true },
|
||||||
|
thinkingTokens: { type: Number, default: 0, required: true },
|
||||||
|
responseTokens: { type: Number, default: 0, required: true },
|
||||||
|
durationMs: { type: Number, default: 0, required: true },
|
||||||
|
durationLabel: { type: String, default: "pending", required: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ChatTurnSchema.index(
|
||||||
|
{
|
||||||
|
user: 1,
|
||||||
|
project: 1,
|
||||||
|
session: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chat-turn-user-project-session-index",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ChatTurnSchema.index(
|
||||||
|
{
|
||||||
|
user: 1,
|
||||||
|
prompt: "text",
|
||||||
|
thinking: "text",
|
||||||
|
response: "text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
weights: {
|
||||||
|
prompt: 10,
|
||||||
|
response: 5,
|
||||||
|
thinking: 1,
|
||||||
|
},
|
||||||
|
name: "chat-turn-user-text-index",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChatTurn = model<IChatTurn>("ChatTurn", ChatTurnSchema);
|
||||||
|
export default ChatTurn;
|
||||||
50
gadget-code/src/models/csrf-token.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
// src/models/csrf-token.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { Schema, Types, model } from "mongoose";
|
||||||
|
import { IUser } from "./user.js";
|
||||||
|
|
||||||
|
import { DtpLog } from "../lib/log.js";
|
||||||
|
const log = new DtpLog({
|
||||||
|
name: "CsrfTokenModel",
|
||||||
|
slug: "csrfToken",
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ICsrfToken {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
created: Date;
|
||||||
|
expires: Date;
|
||||||
|
claimed?: Date;
|
||||||
|
token: string;
|
||||||
|
user?: IUser | Types.ObjectId;
|
||||||
|
ip: string;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* not saved
|
||||||
|
*/
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CsrfTokenSchema = new Schema<ICsrfToken>({
|
||||||
|
created: {
|
||||||
|
type: Date,
|
||||||
|
required: true,
|
||||||
|
default: Date.now,
|
||||||
|
index: -1,
|
||||||
|
expires: "72h",
|
||||||
|
},
|
||||||
|
expires: { type: Date, required: true, default: Date.now, index: -1 },
|
||||||
|
claimed: { type: Date },
|
||||||
|
token: { type: String, required: true, index: 1 },
|
||||||
|
user: { type: Types.ObjectId, ref: "User" },
|
||||||
|
ip: { type: String, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CsrfToken = model<ICsrfToken>("CsrfToken", CsrfTokenSchema);
|
||||||
|
export default CsrfToken;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
log.info("Syncing indexes...");
|
||||||
|
await CsrfToken.syncIndexes();
|
||||||
|
})();
|
||||||
77
gadget-code/src/models/drone-monitor.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
// src/models/drone-monitor.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { Types, Schema, Document, model } from "mongoose";
|
||||||
|
import { IDroneRegistration } from "./drone-registration";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Memory Monitor interface & schema
|
||||||
|
*/
|
||||||
|
export interface IMemoryMonitor {
|
||||||
|
count: number;
|
||||||
|
bytes: number;
|
||||||
|
}
|
||||||
|
export const MemoryMonitorSchema = new Schema<IMemoryMonitor>({
|
||||||
|
count: { type: Number, default: 0, required: true },
|
||||||
|
bytes: { type: Number, default: 0, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* DroneMonitor interface & schema
|
||||||
|
*/
|
||||||
|
export interface IDroneMonitor extends Document {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
registration: IDroneRegistration | Types.ObjectId;
|
||||||
|
timestamp: Date;
|
||||||
|
memory: {
|
||||||
|
rss: number;
|
||||||
|
v8: {
|
||||||
|
heapTotal: number;
|
||||||
|
heapUsed: number;
|
||||||
|
heapExternal: number;
|
||||||
|
};
|
||||||
|
os: {
|
||||||
|
total: number;
|
||||||
|
free: number;
|
||||||
|
};
|
||||||
|
ai: {
|
||||||
|
subagents: IMemoryMonitor;
|
||||||
|
fileOperations: IMemoryMonitor;
|
||||||
|
toolCalls: IMemoryMonitor;
|
||||||
|
};
|
||||||
|
logs: IMemoryMonitor;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export const DroneMonitorSchema = new Schema<IDroneMonitor>({
|
||||||
|
registration: { type: Types.ObjectId, required: true, index: 1 },
|
||||||
|
timestamp: { type: Date, required: true, index: -1 },
|
||||||
|
memory: {
|
||||||
|
rss: { type: Number, required: true },
|
||||||
|
v8: {
|
||||||
|
heapTotal: { type: Number, required: true },
|
||||||
|
heapUsed: { type: Number, required: true },
|
||||||
|
heapExternal: { type: Number, required: true, default: 0 },
|
||||||
|
},
|
||||||
|
os: {
|
||||||
|
total: { type: Number, required: true },
|
||||||
|
free: { type: Number, required: true },
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
subagents: { type: MemoryMonitorSchema, required: true },
|
||||||
|
fileOperations: { type: MemoryMonitorSchema, required: true },
|
||||||
|
toolCalls: { type: MemoryMonitorSchema, required: true },
|
||||||
|
},
|
||||||
|
logs: { type: MemoryMonitorSchema, required: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DroneMonitor = model<IDroneMonitor>(
|
||||||
|
"DroneMonitor",
|
||||||
|
DroneMonitorSchema,
|
||||||
|
);
|
||||||
|
export default DroneMonitor;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await DroneMonitor.syncIndexes();
|
||||||
|
})();
|
||||||
52
gadget-code/src/models/drone-registration.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// src/models/drone-registration.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { Document, Schema, Types, model } from "mongoose";
|
||||||
|
import { IUser } from "./user";
|
||||||
|
|
||||||
|
export enum DroneStatus {
|
||||||
|
Starting = "starting",
|
||||||
|
Available = "available",
|
||||||
|
Busy = "busy",
|
||||||
|
Offline = "offline",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDroneRegistration extends Document {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
user: IUser | Types.ObjectId;
|
||||||
|
hostname: string;
|
||||||
|
status: DroneStatus;
|
||||||
|
currentJobId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DroneRegistrationSchema = new Schema<IDroneRegistration>({
|
||||||
|
createdAt: { type: Date, required: true },
|
||||||
|
updatedAt: { type: Date, required: false },
|
||||||
|
user: { type: Schema.Types.ObjectId, ref: "User", required: true },
|
||||||
|
hostname: { type: String, required: true },
|
||||||
|
status: {
|
||||||
|
type: String,
|
||||||
|
enum: DroneStatus,
|
||||||
|
default: DroneStatus.Starting,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
currentJobId: { type: String, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
DroneRegistrationSchema.index({
|
||||||
|
user: 1,
|
||||||
|
status: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const DroneRegistration = model<IDroneRegistration>(
|
||||||
|
"DroneRegistration",
|
||||||
|
DroneRegistrationSchema,
|
||||||
|
);
|
||||||
|
export default DroneRegistration;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await DroneRegistration.syncIndexes();
|
||||||
|
})();
|
||||||
37
gadget-code/src/models/email-log.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
// src/models/email-log.ts
|
||||||
|
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||||
|
// All Rights Reserved
|
||||||
|
|
||||||
|
import { Schema, Types, Document, model } from "mongoose";
|
||||||
|
|
||||||
|
import { DtpLog } from "../lib/log.js";
|
||||||
|
const log = new DtpLog({
|
||||||
|
name: "EmailLogModel",
|
||||||
|
slug: "emailLog",
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface IEmailLog extends Document {
|
||||||
|
_id: Types.ObjectId;
|
||||||
|
created: Date;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
to_lc: string;
|
||||||
|
subject: string;
|
||||||
|
messageId: string;
|
||||||
|
}
|
||||||
|
export const EmailLogSchema = new Schema<IEmailLog>({
|
||||||
|
created: { type: Date, default: Date.now, required: true, index: -1 },
|
||||||
|
from: { type: String, required: true },
|
||||||
|
to: { type: String, required: true },
|
||||||
|
to_lc: { type: String, required: true, lowercase: true, index: 1 },
|
||||||
|
subject: { type: String, required: true },
|
||||||
|
messageId: { type: String },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EmailLog = model<IEmailLog>("EmailLog", EmailLogSchema);
|
||||||
|
export default EmailLog;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
log.info("Syncing indexes...");
|
||||||
|
await EmailLog.syncIndexes();
|
||||||
|
})();
|
||||||