lambda-erp 0.8.29__tar.gz → 0.8.31__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/PKG-INFO +15 -15
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/README.md +14 -13
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/attachments.py +14 -4
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/auth.py +143 -33
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/bootstrap.py +46 -42
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/chat.py +198 -259
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/demo_limits.py +1 -1
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/main.py +4 -1
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/oauth.py +121 -23
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/providers.py +20 -41
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/admin.py +2 -2
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/analytics.py +351 -43
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/setup.py +16 -8
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/frontend/src/api/client.ts +2 -2
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/database.py +16 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/pyproject.toml +1 -2
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/terraform/README.md +11 -14
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/.gitignore +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/LICENSE +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/__init__.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/deps.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/errors.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/list_values.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/pdf.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/remarks_md.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/__init__.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/accounting.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/actions.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/availability.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/bank_statements.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/documents.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/masters.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/mcp.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/proposals.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/routers/reports.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/services.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/templates/document.html +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/templates/proposal.html +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/api/tool_permissions.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/docs/agents/README.md +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/frontend/LICENSE +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/frontend/README.md +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/model.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.8.29 → lambda_erp-0.8.31}/lambda_erp/utils.py +0 -0
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lambda-erp
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.31
|
|
4
4
|
Summary: Core ERP logic - accounting, sales, purchasing, inventory
|
|
5
5
|
Author: TORUS INVESTMENTS AG
|
|
6
6
|
License-Expression: Apache-2.0
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
Requires-Python: >=3.10
|
|
9
|
-
Requires-Dist: anthropic>=0.40
|
|
10
9
|
Requires-Dist: bcrypt>=5
|
|
11
10
|
Requires-Dist: defusedxml>=0.7
|
|
12
11
|
Requires-Dist: fastapi>=0.115
|
|
@@ -87,8 +86,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
87
86
|
│ - Document forms │ │ - Document CRUD API │
|
|
88
87
|
│ - Reports / Analytics │ │ - Report endpoints │
|
|
89
88
|
│ - Chat (WebSocket) │ │ - Auth (JWT cookie) │
|
|
90
|
-
│ -
|
|
91
|
-
│
|
|
89
|
+
│ - Declarative report │ │ - WebSocket chat gateway │
|
|
90
|
+
│ interpreter for charts │ └────────────┬───────────────┘
|
|
92
91
|
└────────────────────────────┘ │
|
|
93
92
|
▼
|
|
94
93
|
┌──────────────────────────────────────────┐
|
|
@@ -96,8 +95,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
96
95
|
│ - GPT-5.6 drives the reasoning loop │
|
|
97
96
|
│ - Tool-use: document CRUD, search, │
|
|
98
97
|
│ reports, aggregations, analytics │
|
|
99
|
-
│ - Delegates
|
|
100
|
-
│
|
|
98
|
+
│ - Delegates report specs to a separate │
|
|
99
|
+
│ GPT-5.6 Terra specialist call │
|
|
101
100
|
└──────────────────┬───────────────────────┘
|
|
102
101
|
│
|
|
103
102
|
▼
|
|
@@ -117,9 +116,9 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
117
116
|
- **Extensions are first-class to the AI.** A customer deployment registers its own doctypes and master types through plugin seams (`register_doctype`, `register_master`) and the chat discovers them automatically - tool schemas and the system prompt are built per request from the live registries, and fields are introspected from the live table, so there is nothing to teach per type. A CRM lead added by a plugin is searchable in chat from day one, with zero prompt or tool edits.
|
|
118
117
|
- **One shape for every document.** Invoices, sales orders, stock entries, payments - all share a single `Document` base class and the same three-state lifecycle (Draft → Submitted → Cancelled) with `on_submit`/`on_cancel` hooks. The LLM learns the pattern once and drives every doctype the same way. Leading open-source and commercial ERPs have per-model action verbs spread across 150+ core models; each one is a separate tool the model has to get right.
|
|
119
118
|
- **Metadata-driven UI, shared with the LLM.** A single React form component renders every doctype from `frontend/src/lib/doctypes.ts`. The schema the model reasons over and the schema the user sees are literally the same file. Adding a field is two lines - one in the Python class, one in the config - not a new module with hand-written views and inheritance overlays.
|
|
120
|
-
- **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs
|
|
119
|
+
- **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs a custom report, it delegates a bounded declarative report specification to a report-specialist model. No generated report JavaScript is executed in the browser.
|
|
121
120
|
- **Semantic datasets, not free SQL.** The LLM can't write raw SQL; it calls whitelisted semantic datasets (`sales_invoices`, `purchase_invoices`, `ar_open_items`, `stock_balances`, etc.) with whitelisted filters and group-bys. This makes the system auditable without sacrificing flexibility.
|
|
122
|
-
- **Client-side analytics runtime.**
|
|
121
|
+
- **Client-side analytics runtime.** A bounded declarative interpreter groups, aggregates, sorts, and charts whitelisted semantic datasets. Report drafts contain portable JSON specifications, never executable code.
|
|
123
122
|
- **Double-entry invariant enforced.** Every submitted document that touches the GL must balance to zero. The engine adds round-off entries for rounding gaps and refuses to post imbalanced vouchers.
|
|
124
123
|
- **One deployment per customer, simple to operate.** Lambda ERP is built to be self-hosted by a single company for its own books - not as a multi-tenant SaaS. One FastAPI process, one database, one VPS is enough. If you want a hosted offering, we'll ship a dedicated instance per customer.
|
|
125
124
|
|
|
@@ -133,7 +132,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
133
132
|
| Frontend | React + Vite + TypeScript + Tailwind + Recharts |
|
|
134
133
|
| Chat transport | WebSocket |
|
|
135
134
|
| LLM orchestrator | OpenAI (configurable) |
|
|
136
|
-
|
|
|
135
|
+
| Report specialist | OpenAI GPT-5.6 Terra (configurable) |
|
|
137
136
|
| Auth | JWT httponly cookie, three roles + demo |
|
|
138
137
|
|
|
139
138
|
---
|
|
@@ -186,7 +185,7 @@ The result is booked in one step — chart of accounts, sensible default account
|
|
|
186
185
|
Four things had to be true for this to work, and they all became true in the last ~18 months:
|
|
187
186
|
|
|
188
187
|
1. **LLMs can reliably call tools.** A year ago, models would hallucinate tool calls, mangle JSON, or drift after 2–3 steps. Today's frontier models can run an 8-step reasoning loop over a real tool inventory without falling off.
|
|
189
|
-
2. **Costs collapsed.** Generating a custom report via a
|
|
188
|
+
2. **Costs collapsed.** Generating a custom report via a report-specialist model is cents of compute. Even keeping a human reviewer fully in the loop, the marginal cost of "one more report" or "one more dashboard" drops by orders of magnitude — which means companies actually ask for them, instead of living with the defaults.
|
|
190
189
|
3. **Structured output + function calling are first-class.** We can constrain the LLM's outputs to valid tool-call schemas, safe SQL parameters, and typed JSON - which is what makes an AI-native ERP even conceivable as a safe thing to run.
|
|
191
190
|
4. **Greenfield is finally cheaper than retrofit.** Twenty-year ERP codebases have hundreds of bespoke models and thousands of hand-written forms - teaching an LLM to drive that reliably means curating a custom tool layer over every quirk. Starting from scratch around one Document lifecycle and a metadata-driven UI is now cheaper than retrofitting an existing platform.
|
|
192
191
|
|
|
@@ -217,7 +216,7 @@ You need Docker and Compose v2. The canonical installs:
|
|
|
217
216
|
Then, from the repo root:
|
|
218
217
|
|
|
219
218
|
```bash
|
|
220
|
-
cp .env.example .env # add your OPENAI_API_KEY
|
|
219
|
+
cp .env.example .env # add your OPENAI_API_KEY
|
|
221
220
|
docker compose up --build
|
|
222
221
|
```
|
|
223
222
|
|
|
@@ -262,14 +261,15 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
|
|
|
262
261
|
|
|
263
262
|
```
|
|
264
263
|
OPENAI_API_KEY=sk-...
|
|
265
|
-
|
|
266
|
-
ANTHROPIC_CODE_MODEL=claude-opus-4-7 # optional, default shown
|
|
264
|
+
LAMBDA_ERP_REPORT_MODEL=gpt-5.6-terra # optional specialist override; default shown
|
|
267
265
|
LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
|
|
268
266
|
LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
|
|
269
267
|
LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
|
|
270
268
|
```
|
|
271
269
|
|
|
272
|
-
Chat
|
|
270
|
+
Chat and custom-report specification generation share `OPENAI_API_KEY`. Both
|
|
271
|
+
the orchestrator and report specialist default to GPT-5.6 Terra; the specialist
|
|
272
|
+
can be overridden with `LAMBDA_ERP_REPORT_MODEL`.
|
|
273
273
|
|
|
274
274
|
**Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
|
|
275
275
|
|
|
@@ -487,4 +487,4 @@ Release notes live in [CHANGELOG.md](./CHANGELOG.md). Releases are tagged
|
|
|
487
487
|
|
|
488
488
|
## Trademarks and affiliations
|
|
489
489
|
|
|
490
|
-
Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use).
|
|
490
|
+
Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use). The ERP uses the OpenAI API as a customer; you supply your own API key.
|
|
@@ -58,8 +58,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
58
58
|
│ - Document forms │ │ - Document CRUD API │
|
|
59
59
|
│ - Reports / Analytics │ │ - Report endpoints │
|
|
60
60
|
│ - Chat (WebSocket) │ │ - Auth (JWT cookie) │
|
|
61
|
-
│ -
|
|
62
|
-
│
|
|
61
|
+
│ - Declarative report │ │ - WebSocket chat gateway │
|
|
62
|
+
│ interpreter for charts │ └────────────┬───────────────┘
|
|
63
63
|
└────────────────────────────┘ │
|
|
64
64
|
▼
|
|
65
65
|
┌──────────────────────────────────────────┐
|
|
@@ -67,8 +67,8 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
67
67
|
│ - GPT-5.6 drives the reasoning loop │
|
|
68
68
|
│ - Tool-use: document CRUD, search, │
|
|
69
69
|
│ reports, aggregations, analytics │
|
|
70
|
-
│ - Delegates
|
|
71
|
-
│
|
|
70
|
+
│ - Delegates report specs to a separate │
|
|
71
|
+
│ GPT-5.6 Terra specialist call │
|
|
72
72
|
└──────────────────┬───────────────────────┘
|
|
73
73
|
│
|
|
74
74
|
▼
|
|
@@ -88,9 +88,9 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
88
88
|
- **Extensions are first-class to the AI.** A customer deployment registers its own doctypes and master types through plugin seams (`register_doctype`, `register_master`) and the chat discovers them automatically - tool schemas and the system prompt are built per request from the live registries, and fields are introspected from the live table, so there is nothing to teach per type. A CRM lead added by a plugin is searchable in chat from day one, with zero prompt or tool edits.
|
|
89
89
|
- **One shape for every document.** Invoices, sales orders, stock entries, payments - all share a single `Document` base class and the same three-state lifecycle (Draft → Submitted → Cancelled) with `on_submit`/`on_cancel` hooks. The LLM learns the pattern once and drives every doctype the same way. Leading open-source and commercial ERPs have per-model action verbs spread across 150+ core models; each one is a separate tool the model has to get right.
|
|
90
90
|
- **Metadata-driven UI, shared with the LLM.** A single React form component renders every doctype from `frontend/src/lib/doctypes.ts`. The schema the model reasons over and the schema the user sees are literally the same file. Adding a field is two lines - one in the Python class, one in the config - not a new module with hand-written views and inheritance overlays.
|
|
91
|
-
- **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs
|
|
91
|
+
- **Two-model orchestration.** A planner model handles reasoning and tool-use. When it needs a custom report, it delegates a bounded declarative report specification to a report-specialist model. No generated report JavaScript is executed in the browser.
|
|
92
92
|
- **Semantic datasets, not free SQL.** The LLM can't write raw SQL; it calls whitelisted semantic datasets (`sales_invoices`, `purchase_invoices`, `ar_open_items`, `stock_balances`, etc.) with whitelisted filters and group-bys. This makes the system auditable without sacrificing flexibility.
|
|
93
|
-
- **Client-side analytics runtime.**
|
|
93
|
+
- **Client-side analytics runtime.** A bounded declarative interpreter groups, aggregates, sorts, and charts whitelisted semantic datasets. Report drafts contain portable JSON specifications, never executable code.
|
|
94
94
|
- **Double-entry invariant enforced.** Every submitted document that touches the GL must balance to zero. The engine adds round-off entries for rounding gaps and refuses to post imbalanced vouchers.
|
|
95
95
|
- **One deployment per customer, simple to operate.** Lambda ERP is built to be self-hosted by a single company for its own books - not as a multi-tenant SaaS. One FastAPI process, one database, one VPS is enough. If you want a hosted offering, we'll ship a dedicated instance per customer.
|
|
96
96
|
|
|
@@ -104,7 +104,7 @@ Each of those is hours of skilled work today. With a capable LLM in the loop, th
|
|
|
104
104
|
| Frontend | React + Vite + TypeScript + Tailwind + Recharts |
|
|
105
105
|
| Chat transport | WebSocket |
|
|
106
106
|
| LLM orchestrator | OpenAI (configurable) |
|
|
107
|
-
|
|
|
107
|
+
| Report specialist | OpenAI GPT-5.6 Terra (configurable) |
|
|
108
108
|
| Auth | JWT httponly cookie, three roles + demo |
|
|
109
109
|
|
|
110
110
|
---
|
|
@@ -157,7 +157,7 @@ The result is booked in one step — chart of accounts, sensible default account
|
|
|
157
157
|
Four things had to be true for this to work, and they all became true in the last ~18 months:
|
|
158
158
|
|
|
159
159
|
1. **LLMs can reliably call tools.** A year ago, models would hallucinate tool calls, mangle JSON, or drift after 2–3 steps. Today's frontier models can run an 8-step reasoning loop over a real tool inventory without falling off.
|
|
160
|
-
2. **Costs collapsed.** Generating a custom report via a
|
|
160
|
+
2. **Costs collapsed.** Generating a custom report via a report-specialist model is cents of compute. Even keeping a human reviewer fully in the loop, the marginal cost of "one more report" or "one more dashboard" drops by orders of magnitude — which means companies actually ask for them, instead of living with the defaults.
|
|
161
161
|
3. **Structured output + function calling are first-class.** We can constrain the LLM's outputs to valid tool-call schemas, safe SQL parameters, and typed JSON - which is what makes an AI-native ERP even conceivable as a safe thing to run.
|
|
162
162
|
4. **Greenfield is finally cheaper than retrofit.** Twenty-year ERP codebases have hundreds of bespoke models and thousands of hand-written forms - teaching an LLM to drive that reliably means curating a custom tool layer over every quirk. Starting from scratch around one Document lifecycle and a metadata-driven UI is now cheaper than retrofitting an existing platform.
|
|
163
163
|
|
|
@@ -188,7 +188,7 @@ You need Docker and Compose v2. The canonical installs:
|
|
|
188
188
|
Then, from the repo root:
|
|
189
189
|
|
|
190
190
|
```bash
|
|
191
|
-
cp .env.example .env # add your OPENAI_API_KEY
|
|
191
|
+
cp .env.example .env # add your OPENAI_API_KEY
|
|
192
192
|
docker compose up --build
|
|
193
193
|
```
|
|
194
194
|
|
|
@@ -233,14 +233,15 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
|
|
|
233
233
|
|
|
234
234
|
```
|
|
235
235
|
OPENAI_API_KEY=sk-...
|
|
236
|
-
|
|
237
|
-
ANTHROPIC_CODE_MODEL=claude-opus-4-7 # optional, default shown
|
|
236
|
+
LAMBDA_ERP_REPORT_MODEL=gpt-5.6-terra # optional specialist override; default shown
|
|
238
237
|
LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
|
|
239
238
|
LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
|
|
240
239
|
LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
|
|
241
240
|
```
|
|
242
241
|
|
|
243
|
-
Chat
|
|
242
|
+
Chat and custom-report specification generation share `OPENAI_API_KEY`. Both
|
|
243
|
+
the orchestrator and report specialist default to GPT-5.6 Terra; the specialist
|
|
244
|
+
can be overridden with `LAMBDA_ERP_REPORT_MODEL`.
|
|
244
245
|
|
|
245
246
|
**Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
|
|
246
247
|
|
|
@@ -458,4 +459,4 @@ Release notes live in [CHANGELOG.md](./CHANGELOG.md). Releases are tagged
|
|
|
458
459
|
|
|
459
460
|
## Trademarks and affiliations
|
|
460
461
|
|
|
461
|
-
Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use).
|
|
462
|
+
Lambda ERP and [lambda.dev](https://lambda.dev/) are product and trade names of **TORUS INVESTMENTS AG**. It is not affiliated with, endorsed by, or sponsored by OpenAI, Anthropic, SAP, Oracle, Microsoft, or any other company named in this repository. SAP, Business One, S/4HANA, Oracle, NetSuite, Microsoft, Dynamics, OpenAI, GPT, Anthropic, and Claude are trademarks of their respective owners and are referenced here only for descriptive and comparative purposes (nominative fair use). The ERP uses the OpenAI API as a customer; you supply your own API key.
|
|
@@ -238,6 +238,12 @@ async def upload_attachment(
|
|
|
238
238
|
user: dict = Depends(require_role("viewer")),
|
|
239
239
|
):
|
|
240
240
|
"""Upload a chat attachment. Returns metadata the client uses to attach it to a message."""
|
|
241
|
+
db = get_db()
|
|
242
|
+
sessions = db.sql('SELECT user_id FROM "Chat Session" WHERE id = ?', [session_id])
|
|
243
|
+
if not sessions or sessions[0].get("user_id") != user.get("name"):
|
|
244
|
+
# Keep foreign session IDs indistinguishable from nonexistent ones.
|
|
245
|
+
raise HTTPException(status_code=404, detail="Chat session not found")
|
|
246
|
+
|
|
241
247
|
mime = (file.content_type or "application/octet-stream").lower()
|
|
242
248
|
if mime not in ALLOWED_MIME_TYPES:
|
|
243
249
|
# Browsers/OSes often mislabel Office files (e.g. octet-stream) — fall
|
|
@@ -253,9 +259,14 @@ async def upload_attachment(
|
|
|
253
259
|
"and structured bank files (XML/ZIP)."),
|
|
254
260
|
)
|
|
255
261
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
262
|
+
chunks: list[bytes] = []
|
|
263
|
+
size = 0
|
|
264
|
+
while chunk := await file.read(1024 * 1024):
|
|
265
|
+
size += len(chunk)
|
|
266
|
+
if size > MAX_ATTACHMENT_SIZE:
|
|
267
|
+
raise HTTPException(status_code=413, detail=f"File too large. Maximum {MAX_ATTACHMENT_SIZE // (1024 * 1024)} MB.")
|
|
268
|
+
chunks.append(chunk)
|
|
269
|
+
data = b"".join(chunks)
|
|
259
270
|
if not data:
|
|
260
271
|
raise HTTPException(status_code=400, detail="Empty file.")
|
|
261
272
|
|
|
@@ -275,7 +286,6 @@ async def upload_attachment(
|
|
|
275
286
|
),
|
|
276
287
|
)
|
|
277
288
|
|
|
278
|
-
db = get_db()
|
|
279
289
|
# Sanity-cap the number of attachments per session
|
|
280
290
|
cnt = db.sql('SELECT COUNT(*) as c FROM "Chat Attachment" WHERE session_id = ?', [session_id])
|
|
281
291
|
if cnt and cnt[0]["c"] >= MAX_ATTACHMENTS_PER_SESSION:
|
|
@@ -196,6 +196,87 @@ class SetPasswordRequest(BaseModel):
|
|
|
196
196
|
ROLE_HIERARCHY = {"admin": 3, "manager": 2, "public_manager": 2, "viewer": 1}
|
|
197
197
|
ASSIGNABLE_ROLES = {"admin", "manager", "viewer"}
|
|
198
198
|
|
|
199
|
+
SESSION_CREDENTIAL = "session"
|
|
200
|
+
API_KEY_CREDENTIAL = "api_key"
|
|
201
|
+
PUBLIC_DEMO_CREDENTIAL = "public_demo"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def make_auth_principal(
|
|
205
|
+
user: dict,
|
|
206
|
+
credential_type: str,
|
|
207
|
+
*,
|
|
208
|
+
name: str | None = None,
|
|
209
|
+
api_key_id: str | None = None,
|
|
210
|
+
) -> dict:
|
|
211
|
+
"""Return the request-scoped identity used by authorization checks.
|
|
212
|
+
|
|
213
|
+
``User`` and ``Api Key`` rows remain the persistence model. A principal is
|
|
214
|
+
deliberately ephemeral: it records both *who* the owner is and *how* this
|
|
215
|
+
request authenticated, so a role-capped API key cannot be mistaken for an
|
|
216
|
+
interactive browser session at credential-management endpoints.
|
|
217
|
+
"""
|
|
218
|
+
principal = dict(user)
|
|
219
|
+
principal["user_id"] = user["name"]
|
|
220
|
+
principal["name"] = name or user["name"]
|
|
221
|
+
principal["credential_type"] = credential_type
|
|
222
|
+
if api_key_id:
|
|
223
|
+
principal["api_key_id"] = api_key_id
|
|
224
|
+
return principal
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def refresh_auth_principal(principal: dict | None) -> dict | None:
|
|
228
|
+
"""Re-resolve an authenticated principal from live database state.
|
|
229
|
+
|
|
230
|
+
Used at long-lived boundaries (notably chat WebSockets and tool dispatch)
|
|
231
|
+
so disabling/demoting a user or revoking a key takes effect without waiting
|
|
232
|
+
for the connection to close. Legacy in-process callers without an explicit
|
|
233
|
+
credential type are returned unchanged; every real HTTP/WS auth path sets it.
|
|
234
|
+
"""
|
|
235
|
+
if not principal:
|
|
236
|
+
return None
|
|
237
|
+
credential_type = principal.get("credential_type")
|
|
238
|
+
if not credential_type:
|
|
239
|
+
return principal
|
|
240
|
+
|
|
241
|
+
db = get_db()
|
|
242
|
+
if credential_type == API_KEY_CREDENTIAL:
|
|
243
|
+
key_id = principal.get("api_key_id")
|
|
244
|
+
rows = db.sql(
|
|
245
|
+
'SELECT id, owner, role, session_owner, revoked FROM "Api Key" WHERE id = ?',
|
|
246
|
+
[key_id],
|
|
247
|
+
)
|
|
248
|
+
if not rows or rows[0].get("revoked"):
|
|
249
|
+
return None
|
|
250
|
+
key = dict(rows[0])
|
|
251
|
+
owner = db.get_value(
|
|
252
|
+
"User", key["owner"], ["name", "email", "full_name", "role", "enabled"]
|
|
253
|
+
)
|
|
254
|
+
if not owner or not owner.get("enabled"):
|
|
255
|
+
return None
|
|
256
|
+
owner = dict(owner)
|
|
257
|
+
effective_role = (
|
|
258
|
+
key["role"] if _role_rank(key["role"]) <= _role_rank(owner["role"])
|
|
259
|
+
else owner["role"]
|
|
260
|
+
)
|
|
261
|
+
owner["role"] = effective_role
|
|
262
|
+
return make_auth_principal(
|
|
263
|
+
owner,
|
|
264
|
+
API_KEY_CREDENTIAL,
|
|
265
|
+
name=principal.get("name") or key.get("session_owner") or owner["name"],
|
|
266
|
+
api_key_id=key["id"],
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
user_id = principal.get("user_id") or principal.get("name")
|
|
270
|
+
user = db.get_value(
|
|
271
|
+
"User", user_id, ["name", "email", "full_name", "role", "enabled"]
|
|
272
|
+
)
|
|
273
|
+
if not user or not user.get("enabled"):
|
|
274
|
+
return None
|
|
275
|
+
user = dict(user)
|
|
276
|
+
if credential_type == PUBLIC_DEMO_CREDENTIAL and user.get("role") != "public_manager":
|
|
277
|
+
return None
|
|
278
|
+
return make_auth_principal(user, credential_type)
|
|
279
|
+
|
|
199
280
|
|
|
200
281
|
def get_current_user(request: Request) -> dict:
|
|
201
282
|
"""FastAPI dependency: resolve the caller for every cookie-gated REST route.
|
|
@@ -223,26 +304,23 @@ def get_current_user(request: Request) -> dict:
|
|
|
223
304
|
if user_name:
|
|
224
305
|
user = db.get_value("User", user_name, ["name", "email", "full_name", "role", "enabled"])
|
|
225
306
|
if user and user.get("enabled"):
|
|
226
|
-
return dict(user)
|
|
307
|
+
return make_auth_principal(dict(user), SESSION_CREDENTIAL)
|
|
227
308
|
|
|
228
309
|
bearer = _bearer_token(request)
|
|
229
310
|
if bearer is not None:
|
|
230
311
|
if not _setting_enabled(db, "rest_api_enabled"):
|
|
231
312
|
raise HTTPException(status_code=401, detail="REST API key access is disabled")
|
|
232
313
|
key, owner, effective_role = _lookup_api_key(db, bearer)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
"enabled": owner.get("enabled"),
|
|
239
|
-
"api_key_id": key["id"],
|
|
240
|
-
}
|
|
314
|
+
owner = dict(owner)
|
|
315
|
+
owner["role"] = effective_role
|
|
316
|
+
return make_auth_principal(
|
|
317
|
+
owner, API_KEY_CREDENTIAL, api_key_id=key["id"]
|
|
318
|
+
)
|
|
241
319
|
|
|
242
320
|
# Fall back to public manager (demo mode)
|
|
243
321
|
pub = db.sql('SELECT name, email, full_name, role, enabled FROM "User" WHERE role = \'public_manager\' AND enabled = 1')
|
|
244
322
|
if pub:
|
|
245
|
-
return dict(pub[0])
|
|
323
|
+
return make_auth_principal(dict(pub[0]), PUBLIC_DEMO_CREDENTIAL)
|
|
246
324
|
|
|
247
325
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
248
326
|
|
|
@@ -272,6 +350,34 @@ require_manager = require_role("manager")
|
|
|
272
350
|
require_viewer = require_role("viewer")
|
|
273
351
|
|
|
274
352
|
|
|
353
|
+
def require_interactive_user(user: dict = Depends(get_current_user)) -> dict:
|
|
354
|
+
"""Require a real browser session for account/credential management."""
|
|
355
|
+
if user.get("credential_type") != SESSION_CREDENTIAL:
|
|
356
|
+
raise HTTPException(
|
|
357
|
+
status_code=403,
|
|
358
|
+
detail="This account-security operation requires an interactive sign-in.",
|
|
359
|
+
)
|
|
360
|
+
return user
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def require_interactive_role(minimum_role: str):
|
|
364
|
+
"""Require both a browser session and the requested live account role."""
|
|
365
|
+
min_level = ROLE_HIERARCHY[minimum_role]
|
|
366
|
+
|
|
367
|
+
def checker(user: dict = Depends(require_interactive_user)) -> dict:
|
|
368
|
+
if ROLE_HIERARCHY.get(user.get("role"), 0) < min_level:
|
|
369
|
+
raise HTTPException(
|
|
370
|
+
status_code=403,
|
|
371
|
+
detail=f"Requires {minimum_role} role or higher",
|
|
372
|
+
)
|
|
373
|
+
return user
|
|
374
|
+
|
|
375
|
+
return checker
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
require_interactive_admin = require_interactive_role("admin")
|
|
379
|
+
|
|
380
|
+
|
|
275
381
|
def require_non_public_manager(user: dict = Depends(get_current_user)) -> dict:
|
|
276
382
|
user_level = ROLE_HIERARCHY.get(user["role"], 0)
|
|
277
383
|
if user_level < ROLE_HIERARCHY["manager"]:
|
|
@@ -404,7 +510,7 @@ def me(user: dict = Depends(get_current_user)):
|
|
|
404
510
|
|
|
405
511
|
|
|
406
512
|
@router.post("/change-password")
|
|
407
|
-
def change_password(data: ChangePasswordRequest, user: dict = Depends(
|
|
513
|
+
def change_password(data: ChangePasswordRequest, user: dict = Depends(require_interactive_user)):
|
|
408
514
|
"""Change the signed-in user's own password (verifies the current one)."""
|
|
409
515
|
# The public_manager is a shared demo account — no real password to change.
|
|
410
516
|
if user["role"] == "public_manager":
|
|
@@ -426,7 +532,7 @@ def change_password(data: ChangePasswordRequest, user: dict = Depends(get_curren
|
|
|
426
532
|
|
|
427
533
|
|
|
428
534
|
@router.post("/set-password")
|
|
429
|
-
def set_password(data: SetPasswordRequest, user: dict = Depends(
|
|
535
|
+
def set_password(data: SetPasswordRequest, user: dict = Depends(require_interactive_user)):
|
|
430
536
|
"""Set a first password for an account that has none — e.g. a user created
|
|
431
537
|
via social login who wants an email+password fallback. Requires an
|
|
432
538
|
authenticated session; refuses if a usable password already exists (that
|
|
@@ -451,7 +557,7 @@ def set_password(data: SetPasswordRequest, user: dict = Depends(get_current_user
|
|
|
451
557
|
|
|
452
558
|
|
|
453
559
|
@router.post("/invite")
|
|
454
|
-
def create_invite(data: InviteRequest, user: dict = Depends(
|
|
560
|
+
def create_invite(data: InviteRequest, user: dict = Depends(require_interactive_admin)):
|
|
455
561
|
db = get_db()
|
|
456
562
|
|
|
457
563
|
validate_assignable_role(data.role)
|
|
@@ -474,14 +580,14 @@ def create_invite(data: InviteRequest, user: dict = Depends(require_admin)):
|
|
|
474
580
|
|
|
475
581
|
|
|
476
582
|
@router.get("/users")
|
|
477
|
-
def list_users(user: dict = Depends(
|
|
583
|
+
def list_users(user: dict = Depends(require_interactive_admin)):
|
|
478
584
|
db = get_db()
|
|
479
585
|
rows = db.sql('SELECT name, email, full_name, role, enabled, creation FROM "User" ORDER BY creation')
|
|
480
586
|
return [dict(r) for r in rows]
|
|
481
587
|
|
|
482
588
|
|
|
483
589
|
@router.put("/users/{user_name}/role")
|
|
484
|
-
def change_role(user_name: str, data: ChangeRoleRequest, user: dict = Depends(
|
|
590
|
+
def change_role(user_name: str, data: ChangeRoleRequest, user: dict = Depends(require_interactive_admin)):
|
|
485
591
|
validate_assignable_role(data.role)
|
|
486
592
|
|
|
487
593
|
db = get_db()
|
|
@@ -498,7 +604,7 @@ def change_role(user_name: str, data: ChangeRoleRequest, user: dict = Depends(re
|
|
|
498
604
|
|
|
499
605
|
|
|
500
606
|
@router.delete("/users/{user_name}")
|
|
501
|
-
def disable_user(user_name: str, user: dict = Depends(
|
|
607
|
+
def disable_user(user_name: str, user: dict = Depends(require_interactive_admin)):
|
|
502
608
|
if user_name == user["name"]:
|
|
503
609
|
raise HTTPException(status_code=409, detail="Cannot disable yourself")
|
|
504
610
|
|
|
@@ -511,14 +617,14 @@ def disable_user(user_name: str, user: dict = Depends(require_admin)):
|
|
|
511
617
|
|
|
512
618
|
|
|
513
619
|
@router.get("/invites")
|
|
514
|
-
def list_invites(user: dict = Depends(
|
|
620
|
+
def list_invites(user: dict = Depends(require_interactive_admin)):
|
|
515
621
|
db = get_db()
|
|
516
622
|
rows = db.sql('SELECT token, email, role, used, creation FROM "Invite" ORDER BY creation DESC')
|
|
517
623
|
return [dict(r) for r in rows]
|
|
518
624
|
|
|
519
625
|
|
|
520
626
|
@router.delete("/invites/{token}")
|
|
521
|
-
def revoke_invite(token: str, user: dict = Depends(
|
|
627
|
+
def revoke_invite(token: str, user: dict = Depends(require_interactive_admin)):
|
|
522
628
|
"""Revoke a pending (unused) invite so its link no longer works."""
|
|
523
629
|
db = get_db()
|
|
524
630
|
rows = db.sql('SELECT token, used FROM "Invite" WHERE token = ?', [token])
|
|
@@ -532,7 +638,7 @@ def revoke_invite(token: str, user: dict = Depends(require_admin)):
|
|
|
532
638
|
|
|
533
639
|
|
|
534
640
|
@router.post("/public-manager")
|
|
535
|
-
def create_public_manager(user: dict = Depends(
|
|
641
|
+
def create_public_manager(user: dict = Depends(require_interactive_admin)):
|
|
536
642
|
"""Create (or re-enable) the public manager account for demo mode.
|
|
537
643
|
|
|
538
644
|
Also seeds the chat-replay artefacts the "Enter Live Demo" script
|
|
@@ -580,7 +686,7 @@ def create_public_manager(user: dict = Depends(require_admin)):
|
|
|
580
686
|
|
|
581
687
|
|
|
582
688
|
@router.delete("/public-manager")
|
|
583
|
-
def remove_public_manager(user: dict = Depends(
|
|
689
|
+
def remove_public_manager(user: dict = Depends(require_interactive_admin)):
|
|
584
690
|
"""Disable the public manager account."""
|
|
585
691
|
db = get_db()
|
|
586
692
|
existing = db.sql('SELECT name FROM "User" WHERE role = \'public_manager\'')
|
|
@@ -690,7 +796,7 @@ def ensure_seed_admin() -> dict | None:
|
|
|
690
796
|
|
|
691
797
|
|
|
692
798
|
@router.get("/settings")
|
|
693
|
-
def get_settings(user: dict = Depends(
|
|
799
|
+
def get_settings(user: dict = Depends(require_interactive_user)):
|
|
694
800
|
db = get_db()
|
|
695
801
|
rows = db.sql('SELECT key, value FROM "Settings"')
|
|
696
802
|
settings = {r["key"]: r["value"] for r in rows}
|
|
@@ -702,7 +808,7 @@ def get_settings(user: dict = Depends(get_current_user)):
|
|
|
702
808
|
|
|
703
809
|
|
|
704
810
|
@router.put("/settings")
|
|
705
|
-
def update_settings(data: dict, user: dict = Depends(
|
|
811
|
+
def update_settings(data: dict, user: dict = Depends(require_interactive_admin)):
|
|
706
812
|
db = get_db()
|
|
707
813
|
for key, value in data.items():
|
|
708
814
|
existing = db.sql('SELECT key FROM "Settings" WHERE key = ?', [key])
|
|
@@ -857,13 +963,17 @@ def get_api_caller(request: Request) -> dict:
|
|
|
857
963
|
raise HTTPException(status_code=401, detail="Missing or malformed Authorization header")
|
|
858
964
|
key, owner, effective_role = _lookup_api_key(db, token)
|
|
859
965
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
966
|
+
owner = dict(owner)
|
|
967
|
+
owner["role"] = effective_role
|
|
968
|
+
principal = make_auth_principal(
|
|
969
|
+
owner,
|
|
970
|
+
API_KEY_CREDENTIAL,
|
|
971
|
+
name=key["session_owner"],
|
|
972
|
+
api_key_id=key["id"],
|
|
973
|
+
)
|
|
974
|
+
principal["full_name"] = f"{owner.get('full_name') or key['owner']} (API)"
|
|
975
|
+
principal["api_key_user"] = key["owner"]
|
|
976
|
+
return principal
|
|
867
977
|
|
|
868
978
|
|
|
869
979
|
class ApiKeyCreate(BaseModel):
|
|
@@ -904,7 +1014,7 @@ def _require_key_access(key: dict, user: dict) -> None:
|
|
|
904
1014
|
|
|
905
1015
|
|
|
906
1016
|
@router.get("/api-keys")
|
|
907
|
-
def list_api_keys(user: dict = Depends(
|
|
1017
|
+
def list_api_keys(user: dict = Depends(require_interactive_user)):
|
|
908
1018
|
"""List API keys (metadata only — the token is never returned).
|
|
909
1019
|
|
|
910
1020
|
Users see their own keys; admins see everyone's. Each key carries its
|
|
@@ -930,7 +1040,7 @@ def list_api_keys(user: dict = Depends(require_viewer)):
|
|
|
930
1040
|
|
|
931
1041
|
|
|
932
1042
|
@router.post("/api-keys")
|
|
933
|
-
def create_api_key(data: ApiKeyCreate, user: dict = Depends(
|
|
1043
|
+
def create_api_key(data: ApiKeyCreate, user: dict = Depends(require_interactive_user)):
|
|
934
1044
|
"""Create an API key for YOURSELF. Returns the full token exactly once.
|
|
935
1045
|
|
|
936
1046
|
Self-service: any logged-in (non-demo) user can mint keys, because a key
|
|
@@ -975,7 +1085,7 @@ def create_api_key(data: ApiKeyCreate, user: dict = Depends(require_viewer)):
|
|
|
975
1085
|
|
|
976
1086
|
|
|
977
1087
|
@router.post("/api-keys/{key_id}/revoke")
|
|
978
|
-
def revoke_api_key(key_id: str, user: dict = Depends(
|
|
1088
|
+
def revoke_api_key(key_id: str, user: dict = Depends(require_interactive_user)):
|
|
979
1089
|
"""Revoke (soft-disable) an API key. Owner or admin."""
|
|
980
1090
|
db = get_db()
|
|
981
1091
|
key = _get_key_or_404(db, key_id)
|
|
@@ -986,7 +1096,7 @@ def revoke_api_key(key_id: str, user: dict = Depends(require_viewer)):
|
|
|
986
1096
|
|
|
987
1097
|
|
|
988
1098
|
@router.delete("/api-keys/{key_id}")
|
|
989
|
-
def delete_api_key(key_id: str, user: dict = Depends(
|
|
1099
|
+
def delete_api_key(key_id: str, user: dict = Depends(require_interactive_user)):
|
|
990
1100
|
"""Hard-delete a REVOKED API key. Owner or admin.
|
|
991
1101
|
|
|
992
1102
|
Two-step by design: a live key must be revoked first (409 otherwise), so a
|