Initial import: ligbox-ops-platform + specs + LAPTOP + obsidian merge (CT130)

Source: VM122 /opt + obsidian-infra + LAPTOP
Hub: CT130 spec-hub 10.10.10.130
This commit is contained in:
Ligbox Spec Hub 2026-06-19 17:26:41 +00:00
commit 3a2c64834b
450 changed files with 102927 additions and 0 deletions

View file

@ -0,0 +1,48 @@
---
description: Comunicação sempre em português do Brasil (pt-BR)
alwaysApply: true
---
# Idioma — Português do Brasil
Roger prefere comunicação **somente em português do Brasil (pt-BR)**.
## Obrigatório
- Escrever todas as respostas, explicações, relatórios e mensagens ao usuário em **pt-BR**.
- Usar vocabulário, ortografia e convenções do **Brasil**, não de Portugal.
- Chamar o usuário de **Roger**.
## Ortografia e vocabulário (pt-BR)
| Evitar (PT-PT) | Usar (pt-BR) |
|----------------|--------------|
| activação | ativação |
| ecrã | tela |
| ficheiro | arquivo |
| utilizador | usuário |
| correio electrónico | e-mail |
| telemóvel | celular |
| palavra-passe | senha |
| a carregar | carregando |
| guardar | salvar |
| eliminar | excluir / remover |
| registo / registar | cadastro / cadastrar |
| sessão terminada | sessão encerrada |
| equipa | equipe |
| factores | fatores |
| actualizar | atualizar |
| servidor (no sentido de atendente) | servidor (infra) / atendente (pessoa) |
## Tom
- Linguagem técnica clara, direta e profissional.
- Frases completas; evitar tom telegráfico.
- Manter termos técnicos em inglês quando forem padrão da indústria (API, deploy, SSH, Docker, etc.).
## UI e documentação do projeto
- **Todo** texto visível ao usuário (HTML, JS, API, e-mails, tickets) deve estar em **pt-BR**.
- Locale de datas: `pt-BR` (nunca `pt-PT`).
- Atributo HTML: `lang="pt-BR"`.
- Não misturar vocabulário de Portugal no app.

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
.env
.env.*
!.env.example
data/
*.db
__pycache__/
*.pyc
node_modules/
.git/
*.log
CREDENCIAIS*.txt
**/CREDENCIAIS_SERVICOS*.txt

184
.specify/extensions.yml Normal file
View file

@ -0,0 +1,184 @@
installed:
- agent-context
- git
settings:
auto_execute_hooks: true
hooks:
before_constitution:
- extension: git
command: speckit.git.initialize
enabled: true
optional: false
priority: 10
prompt: Execute speckit.git.initialize?
description: Initialize Git repository before constitution setup
condition: null
before_specify:
- extension: git
command: speckit.git.feature
enabled: true
optional: false
priority: 10
prompt: Execute speckit.git.feature?
description: Create feature branch before specification
condition: null
before_clarify:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit outstanding changes before clarification?
description: Auto-commit before spec clarification
condition: null
before_plan:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit outstanding changes before planning?
description: Auto-commit before implementation planning
condition: null
before_tasks:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit outstanding changes before task generation?
description: Auto-commit before task generation
condition: null
before_implement:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit outstanding changes before implementation?
description: Auto-commit before implementation
condition: null
before_checklist:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit outstanding changes before checklist?
description: Auto-commit before checklist generation
condition: null
before_analyze:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit outstanding changes before analysis?
description: Auto-commit before analysis
condition: null
before_taskstoissues:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit outstanding changes before issue sync?
description: Auto-commit before tasks-to-issues conversion
condition: null
after_constitution:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit constitution changes?
description: Auto-commit after constitution update
condition: null
after_specify:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit specification changes?
description: Auto-commit after specification
condition: null
- extension: agent-context
command: speckit.agent-context.update
enabled: true
optional: true
priority: 10
prompt: Execute speckit.agent-context.update?
description: Refresh agent context after specification
condition: null
after_clarify:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit clarification changes?
description: Auto-commit after spec clarification
condition: null
after_plan:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit plan changes?
description: Auto-commit after implementation planning
condition: null
- extension: agent-context
command: speckit.agent-context.update
enabled: true
optional: true
priority: 10
prompt: Execute speckit.agent-context.update?
description: Refresh agent context after planning
condition: null
after_tasks:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit task changes?
description: Auto-commit after task generation
condition: null
after_implement:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit implementation changes?
description: Auto-commit after implementation
condition: null
after_checklist:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit checklist changes?
description: Auto-commit after checklist generation
condition: null
after_analyze:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit analysis results?
description: Auto-commit after analysis
condition: null
after_taskstoissues:
- extension: git
command: speckit.git.commit
enabled: true
optional: true
priority: 10
prompt: Commit after syncing issues?
description: Auto-commit after tasks-to-issues conversion
condition: null

View file

@ -0,0 +1,37 @@
{
"schema_version": "1.0",
"extensions": {
"git": {
"version": "1.0.0",
"source": "local",
"manifest_hash": "sha256:9731aa8143a72fbebfdb440f155038ab42642517c2b2bdbbf67c8fdbe076ed79",
"enabled": true,
"priority": 10,
"registered_commands": {
"cursor-agent": [
"speckit.git.feature",
"speckit.git.validate",
"speckit.git.remote",
"speckit.git.initialize",
"speckit.git.commit"
]
},
"registered_skills": [],
"installed_at": "2026-06-08T16:25:46.662741+00:00"
},
"agent-context": {
"version": "1.0.0",
"source": "local",
"manifest_hash": "sha256:9a1dc02d2d0139bb03860392ecacef79183be2c442feda2f9ccaa4e5907b1e47",
"enabled": true,
"priority": 10,
"registered_commands": {
"cursor-agent": [
"speckit.agent-context.update"
]
},
"registered_skills": [],
"installed_at": "2026-06-08T16:25:46.753548+00:00"
}
}
}

View file

@ -0,0 +1,57 @@
# Coding Agent Context Extension
This bundled extension manages the **coding agent context/instruction file** (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `GEMINI.md`, …) for the active integration.
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`).
## Why an extension?
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Extracting this behavior into a dedicated extension lets users:
- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file.
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — both the Python layer and the bundled scripts honor the same `context_markers` value.
- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`).
## Commands
| Command | Description |
|---------|-------------|
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
## Configuration
All configuration flows through the extension's own config file at
`.specify/extensions/agent-context/agent-context-config.yml`:
```yaml
# Path to the coding agent context file managed by this extension
context_file: CLAUDE.md
# Delimiters for the managed Spec Kit section
context_markers:
start: "<!-- SPECKIT START -->"
end: "<!-- SPECKIT END -->"
```
- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`.
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
## Requirements
The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available).
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports *"PyYAML is required … not available in the current Python environment"*, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
```bash
pip install pyyaml
# or target the specific interpreter Spec Kit uses:
/path/to/speckit-python -m pip install pyyaml
```
## Disable
```bash
specify extension disable agent-context
```
When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).

View file

@ -0,0 +1,4 @@
context_file: .cursor/rules/specify-rules.mdc
context_markers:
start: <!-- SPECKIT START -->
end: <!-- SPECKIT END -->

View file

@ -0,0 +1,26 @@
---
description: "Refresh the managed Spec Kit section in the coding agent context file"
---
# Update Coding Agent Context
Refresh the managed Spec Kit section inside the active coding agent's context/instruction file (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`).
## Behavior
The script reads the agent-context extension config at
`.specify/extensions/agent-context/agent-context-config.yml` to discover:
- `context_file` — the path of the coding agent context file to manage.
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/<feature>/plan.md`).
If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully.
## Execution
- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]`
- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]`
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`.

View file

@ -0,0 +1,34 @@
schema_version: "1.0"
extension:
id: agent-context
name: "Coding Agent Context"
version: "1.0.0"
description: "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers"
author: spec-kit-core
repository: https://github.com/github/spec-kit
license: MIT
requires:
speckit_version: ">=0.2.0"
provides:
commands:
- name: speckit.agent-context.update
file: commands/speckit.agent-context.update.md
description: "Refresh the managed Spec Kit section in the coding agent context file"
hooks:
after_specify:
command: speckit.agent-context.update
optional: true
description: "Refresh agent context after specification"
after_plan:
command: speckit.agent-context.update
optional: true
description: "Refresh agent context after planning"
tags:
- "agent"
- "context"
- "core"

View file

@ -0,0 +1,200 @@
#!/usr/bin/env bash
# update-agent-context.sh
#
# Refresh the managed Spec Kit section in the coding agent's context file
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
#
# Reads `context_file` and `context_markers.{start,end}` from the
# agent-context extension config:
# .specify/extensions/agent-context/agent-context-config.yml
#
# Usage: update-agent-context.sh [plan_path]
#
# When `plan_path` is omitted, the script picks the most recently modified
# `specs/*/plan.md` if any exist, otherwise emits the section without a
# concrete plan path.
set -euo pipefail
PROJECT_ROOT="$(pwd)"
EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml"
DEFAULT_START="<!-- SPECKIT START -->"
DEFAULT_END="<!-- SPECKIT END -->"
if [[ ! -f "$EXT_CONFIG" ]]; then
echo "agent-context: $EXT_CONFIG not found; nothing to do." >&2
exit 0
fi
# Locate a suitable Python interpreter (python3, then python).
_python=""
if command -v python3 >/dev/null 2>&1; then
_python="python3"
elif command -v python >/dev/null 2>&1 && python --version 2>&1 | grep -q "^Python 3"; then
_python="python"
fi
if [[ -z "$_python" ]]; then
echo "agent-context: Python 3 not found on PATH; skipping update." >&2
exit 0
fi
# Parse extension config once; emit three newline-separated fields:
# context_file, context_markers.start, context_markers.end
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY'
import sys
try:
import yaml
except ImportError:
print(
"agent-context: PyYAML is required to parse extension config but is not available "
"in the current Python environment.\n"
" To resolve: pip install pyyaml (or install it into the environment used by python3).\n"
" Context file will not be updated until PyYAML is importable.",
file=sys.stderr,
)
sys.exit(2)
try:
with open(sys.argv[1], "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
except Exception as exc:
print(
f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.",
file=sys.stderr,
)
sys.exit(2)
if not isinstance(data, dict):
data = {}
def get_str(obj, *keys):
node = obj
for k in keys:
if isinstance(node, dict) and k in node:
node = node[k]
else:
return ""
return node if isinstance(node, str) else ""
print(get_str(data, "context_file"))
print(get_str(data, "context_markers", "start"))
print(get_str(data, "context_markers", "end"))
PY
)"; then
echo "agent-context: skipping update (see above for details)." >&2
exit 0
fi
_opts_lines=()
while IFS= read -r _line || [[ -n "$_line" ]]; do
_opts_lines+=("$_line")
done < <(printf '%s\n' "$_raw_opts")
if (( ${#_opts_lines[@]} < 3 )); then
echo "agent-context: malformed config parser output; expected 3 lines (context_file, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
exit 0
fi
CONTEXT_FILE="${_opts_lines[0]}"
MARKER_START="${_opts_lines[1]}"
MARKER_END="${_opts_lines[2]}"
if [[ -z "$CONTEXT_FILE" ]]; then
echo "agent-context: context_file not set in extension config; nothing to do." >&2
exit 0
fi
# Reject absolute paths, backslash separators, and '..' path segments in context_file
if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then
echo "agent-context: context_file must be a project-relative path; got '$CONTEXT_FILE'." >&2
exit 1
fi
if [[ "$CONTEXT_FILE" == *\\* ]]; then
echo "agent-context: context_file must not contain backslash separators; got '$CONTEXT_FILE'." >&2
exit 1
fi
IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE"
for _seg in "${_cf_parts[@]}"; do
if [[ "$_seg" == ".." ]]; then
echo "agent-context: context_file must not contain '..' path segments; got '$CONTEXT_FILE'." >&2
exit 1
fi
done
unset _cf_parts _seg
[[ -z "$MARKER_START" ]] && MARKER_START="$DEFAULT_START"
[[ -z "$MARKER_END" ]] && MARKER_END="$DEFAULT_END"
PLAN_PATH="${1:-}"
if [[ -z "$PLAN_PATH" ]]; then
# Pick the most recently modified plan.md one level deep (specs/<feature>/plan.md).
# Use find + sort by modification time to avoid ls/head fragility with
# spaces in paths or SIGPIPE from pipefail.
_plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY'
import sys, os
from pathlib import Path
specs = Path(sys.argv[1]) / "specs"
plans = sorted(
specs.glob("*/plan.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
print(plans[0] if plans else "")
PY
)"
if [[ -n "$_plan_abs" ]]; then
PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}"
fi
fi
CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE"
mkdir -p "$(dirname "$CTX_PATH")"
# Build the managed section
TMP_SECTION="$(mktemp)"
trap 'rm -f "$TMP_SECTION"' EXIT
{
echo "$MARKER_START"
echo "For additional context about technologies to be used, project structure,"
echo "shell commands, and other important information, read the current plan"
if [[ -n "$PLAN_PATH" ]]; then
echo "at $PLAN_PATH"
fi
echo "$MARKER_END"
} > "$TMP_SECTION"
"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
import sys, os
ctx_path, start, end, section_path = sys.argv[1:5]
with open(section_path, "r", encoding="utf-8") as fh:
section = fh.read().rstrip("\n") + "\n"
if os.path.exists(ctx_path):
with open(ctx_path, "r", encoding="utf-8-sig") as fh:
content = fh.read()
s = content.find(start)
e = content.find(end, s if s != -1 else 0)
if s != -1 and e != -1 and e > s:
end_of_marker = e + len(end)
if end_of_marker < len(content) and content[end_of_marker] == "\r":
end_of_marker += 1
if end_of_marker < len(content) and content[end_of_marker] == "\n":
end_of_marker += 1
new_content = content[:s] + section + content[end_of_marker:]
elif s != -1:
new_content = content[:s] + section
elif e != -1:
end_of_marker = e + len(end)
if end_of_marker < len(content) and content[end_of_marker] == "\r":
end_of_marker += 1
if end_of_marker < len(content) and content[end_of_marker] == "\n":
end_of_marker += 1
new_content = section + content[end_of_marker:]
else:
if content and not content.endswith("\n"):
content += "\n"
new_content = (content + "\n" + section) if content else section
else:
new_content = section
new_content = new_content.replace("\r\n", "\n").replace("\r", "\n")
with open(ctx_path, "wb") as fh:
fh.write(new_content.encode("utf-8"))
PY
echo "agent-context: updated $CONTEXT_FILE"

View file

@ -0,0 +1,237 @@
#!/usr/bin/env pwsh
# update-agent-context.ps1
#
# Refresh the managed Spec Kit section in the coding agent's context file
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
#
# Reads `context_file` and `context_markers.{start,end}` from the
# agent-context extension config:
# .specify/extensions/agent-context/agent-context-config.yml
#
# Usage: update-agent-context.ps1 [plan_path]
[CmdletBinding()]
param(
[Parameter(Position = 0)]
[string]$PlanPath
)
function Get-ConfigValue {
param(
[AllowNull()][object]$Object,
[Parameter(Mandatory = $true)][string]$Key
)
if ($null -eq $Object) {
return $null
}
if ($Object -is [System.Collections.IDictionary]) {
return $Object[$Key]
}
$prop = $Object.PSObject.Properties[$Key]
if ($prop) {
return $prop.Value
}
return $null
}
function Test-ConfigObject {
param(
[AllowNull()][object]$Object
)
if ($null -eq $Object) {
return $false
}
if ($Object -is [System.Collections.IDictionary]) {
return $true
}
if ($Object -is [System.Management.Automation.PSCustomObject]) {
return $true
}
return $false
}
$ErrorActionPreference = 'Stop'
$DefaultStart = '<!-- SPECKIT START -->'
$DefaultEnd = '<!-- SPECKIT END -->'
$ProjectRoot = (Get-Location).Path
$ExtConfig = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-config.yml'
if (-not (Test-Path -LiteralPath $ExtConfig)) {
Write-Warning "agent-context: $ExtConfig not found; nothing to do."
exit 0
}
$Options = $null
if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) {
try {
$Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop
} catch {
# fall through to Python fallback
}
}
if ($null -eq $Options) {
# ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML.
$pythonCmd = $null
foreach ($candidate in @('python3', 'python')) {
if (Get-Command $candidate -ErrorAction SilentlyContinue) {
# Verify it is Python 3
$verOut = & $candidate --version 2>&1
if ($verOut -match 'Python 3') {
$pythonCmd = $candidate
break
}
}
}
if ($pythonCmd) {
try {
$jsonOut = & $pythonCmd -c @'
import json
import sys
try:
import yaml
except ImportError:
print(
"agent-context: PyYAML is required to parse extension config; cannot update context.",
file=sys.stderr,
)
sys.exit(2)
try:
with open(sys.argv[1], "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
except Exception as exc:
print(
f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.",
file=sys.stderr,
)
sys.exit(2)
if not isinstance(data, dict):
data = {}
print(json.dumps(data))
'@ $ExtConfig
if ($LASTEXITCODE -eq 0 -and $jsonOut) {
$Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop
}
} catch {
$Options = $null
}
}
if (-not $Options) {
Write-Warning "agent-context: unable to parse $ExtConfig; skipping update."
exit 0
}
}
if (-not (Test-ConfigObject -Object $Options)) {
Write-Warning "agent-context: $ExtConfig must contain a YAML mapping; skipping update."
exit 0
}
$ContextFile = Get-ConfigValue -Object $Options -Key 'context_file'
if (-not $ContextFile) {
Write-Warning 'agent-context: context_file not set in extension config; nothing to do.'
exit 0
}
# Reject absolute paths and '..' path segments in context_file
if ([System.IO.Path]::IsPathRooted($ContextFile)) {
Write-Warning "agent-context: context_file must be a project-relative path; got '$ContextFile'."
exit 1
}
$cfSegments = $ContextFile -split '[/\\]'
if ($cfSegments -contains '..') {
Write-Warning "agent-context: context_file must not contain '..' path segments; got '$ContextFile'."
exit 1
}
$MarkerStart = $DefaultStart
$MarkerEnd = $DefaultEnd
$cm = Get-ConfigValue -Object $Options -Key 'context_markers'
if ($cm) {
$cmStart = Get-ConfigValue -Object $cm -Key 'start'
if ($cmStart -is [string] -and $cmStart) {
$MarkerStart = $cmStart
}
$cmEnd = Get-ConfigValue -Object $cm -Key 'end'
if ($cmEnd -is [string] -and $cmEnd) {
$MarkerEnd = $cmEnd
}
}
if (-not $PlanPath) {
# Discover plan.md exactly one level deep (specs/<feature>/plan.md),
# matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under
# $ErrorActionPreference = 'Stop' don't abort the script.
try {
$specsDir = Join-Path $ProjectRoot 'specs'
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
Where-Object { $_ } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($candidate) {
$PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/')
}
} catch {
# Non-fatal: continue without a plan path.
}
}
$CtxPath = Join-Path $ProjectRoot $ContextFile
$CtxDir = Split-Path -Parent $CtxPath
if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) {
New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null
}
$lines = @($MarkerStart,
'For additional context about technologies to be used, project structure,',
'shell commands, and other important information, read the current plan')
if ($PlanPath) {
$lines += "at $PlanPath"
}
$lines += $MarkerEnd
$Section = ($lines -join "`n") + "`n"
if (Test-Path -LiteralPath $CtxPath) {
$rawBytes = [System.IO.File]::ReadAllBytes($CtxPath)
# Strip UTF-8 BOM if present
if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) {
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3)
} else {
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes)
}
$s = $content.IndexOf($MarkerStart)
$e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) }
if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) {
$endOfMarker = $e + $MarkerEnd.Length
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
$newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker)
} elseif ($s -ge 0) {
$newContent = $content.Substring(0, $s) + $Section
} elseif ($e -ge 0) {
$endOfMarker = $e + $MarkerEnd.Length
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
$newContent = $Section + $content.Substring($endOfMarker)
} else {
if ($content -and -not $content.EndsWith("`n")) { $content += "`n" }
if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section }
}
} else {
$newContent = $Section
}
$newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n")
[System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "agent-context: updated $ContextFile"

View file

@ -0,0 +1,100 @@
# Git Branching Workflow Extension
Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit.
## Overview
This extension provides Git operations as an optional, self-contained module. It manages:
- **Repository initialization** with configurable commit messages
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
- **Branch validation** to ensure branches follow naming conventions
- **Git remote detection** for GitHub integration (e.g., issue creation)
- **Auto-commit** after core commands (configurable per-command with custom messages)
## Commands
| Command | Description |
|---------|-------------|
| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message |
| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering |
| `speckit.git.validate` | Validate current branch follows feature branch naming conventions |
| `speckit.git.remote` | Detect Git remote URL for GitHub integration |
| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) |
## Hooks
| Event | Command | Optional | Description |
|-------|---------|----------|-------------|
| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution |
| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification |
| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification |
| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning |
| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation |
| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation |
| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist |
| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis |
| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync |
| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update |
| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification |
| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification |
| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning |
| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation |
| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation |
| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist |
| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis |
| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync |
## Configuration
Configuration is stored in `.specify/extensions/git/git-config.yml`:
```yaml
# Branch numbering strategy: "sequential" or "timestamp"
branch_numbering: sequential
# Custom commit message for git init
init_commit_message: "[Spec Kit] Initial commit"
# Auto-commit per command (all disabled by default)
# Example: enable auto-commit after specify
auto_commit:
default: false
after_specify:
enabled: true
message: "[Spec Kit] Add specification"
```
## Installation
```bash
# Install the bundled git extension (no network required)
specify extension add git
```
## Disabling
```bash
# Disable the git extension (spec creation continues without branching)
specify extension disable git
# Re-enable it
specify extension enable git
```
## Graceful Degradation
When Git is not installed or the directory is not a Git repository:
- Spec directories are still created under `specs/`
- Branch creation is skipped with a warning
- Branch validation is skipped with a warning
- Remote detection returns empty results
## Scripts
The extension bundles cross-platform scripts:
- `scripts/bash/create-new-feature.sh` — Bash implementation
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)

View file

@ -0,0 +1,48 @@
---
description: "Auto-commit changes after a Spec Kit command completes"
---
# Auto-Commit Changes
Automatically stage and commit all changes after a Spec Kit command completes.
## Behavior
This command is invoked as a hook after (or before) core commands. It:
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
3. Looks up the specific event key to see if auto-commit is enabled
4. Falls back to `auto_commit.default` if no event-specific key exists
5. Uses the per-command `message` if configured, otherwise a default message
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
## Execution
Determine the event name from the hook that triggered this command, then run the script:
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
## Configuration
In `.specify/extensions/git/git-config.yml`:
```yaml
auto_commit:
default: false # Global toggle — set true to enable for all commands
after_specify:
enabled: true # Override per-command
message: "[Spec Kit] Add specification"
after_plan:
enabled: false
message: "[Spec Kit] Add implementation plan"
```
## Graceful Degradation
- If Git is not available or the current directory is not a repository: skips with a warning
- If no config file exists: skips (disabled by default)
- If no changes to commit: skips with a message

View file

@ -0,0 +1,67 @@
---
description: "Create a feature branch with sequential or timestamp numbering"
---
# Create Feature Branch
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `__SPECKIT_COMMAND_SPECIFY__` workflow.
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Environment Variable Override
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
- `--short-name`, `--number`, and `--timestamp` flags are ignored
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
## Prerequisites
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, warn the user and skip branch creation
## Branch Numbering Mode
Determine the branch numbering strategy by checking configuration in this order:
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
3. Default to `sequential` if neither exists
## Execution
Generate a concise short name (2-4 words) for the branch:
- Analyze the feature description and extract the most meaningful keywords
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
Run the appropriate script based on your platform:
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
**IMPORTANT**:
- Do NOT pass `--number` — the script determines the correct next number automatically
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
- You must only ever run this script once per feature
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
## Graceful Degradation
If Git is not installed or the current directory is not a Git repository:
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
## Output
The script outputs JSON with:
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- `FEATURE_NUM`: The numeric or timestamp prefix used

View file

@ -0,0 +1,49 @@
---
description: "Initialize a Git repository with an initial commit"
---
# Initialize Git Repository
Initialize a Git repository in the current project directory if one does not already exist.
## Execution
Run the appropriate script from the project root:
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
If the extension scripts are not found, fall back to:
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
The script handles all checks internally:
- Skips if Git is not available
- Skips if already inside a Git repository
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
## Customization
Replace the script to add project-specific Git initialization steps:
- Custom `.gitignore` templates
- Default branch naming (`git config init.defaultBranch`)
- Git LFS setup
- Git hooks installation
- Commit signing configuration
- Git Flow initialization
## Output
On success:
- `[OK] Git repository initialized`
## Graceful Degradation
If Git is not installed:
- Warn the user
- Skip repository initialization
- The project continues to function without Git (specs can still be created under `specs/`)
If Git is installed but `git init`, `git add .`, or `git commit` fails:
- Surface the error to the user
- Stop this command rather than continuing with a partially initialized repository

View file

@ -0,0 +1,45 @@
---
description: "Detect Git remote URL for GitHub integration"
---
# Detect Git Remote URL
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and return empty:
```
[specify] Warning: Git repository not detected; cannot determine remote URL
```
## Execution
Run the following command to get the remote URL:
```bash
git config --get remote.origin.url
```
## Output
Parse the remote URL and determine:
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
3. **Is GitHub**: Whether the remote points to a GitHub repository
Supported URL formats:
- HTTPS: `https://github.com/<owner>/<repo>.git`
- SSH: `git@github.com:<owner>/<repo>.git`
> [!CAUTION]
> ONLY report a GitHub repository if the remote URL actually points to github.com.
> Do NOT assume the remote is GitHub if the URL format doesn't match.
## Graceful Degradation
If Git is not installed, the directory is not a Git repository, or no remote is configured:
- Return an empty result
- Do NOT error — other workflows should continue without Git remote information

View file

@ -0,0 +1,49 @@
---
description: "Validate current branch follows feature branch naming conventions"
---
# Validate Feature Branch
Validate that the current Git branch follows the expected feature branch naming conventions.
## Prerequisites
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
- If Git is not available, output a warning and skip validation:
```
[specify] Warning: Git repository not detected; skipped branch validation
```
## Validation Rules
Get the current branch name:
```bash
git rev-parse --abbrev-ref HEAD
```
The branch name must match one of these patterns:
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
## Execution
If on a feature branch (matches either pattern):
- Output: `✓ On feature branch: <branch-name>`
- Check if the corresponding spec directory exists under `specs/`:
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
- If spec directory exists: `✓ Spec directory found: <path>`
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
If NOT on a feature branch:
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
## Graceful Degradation
If Git is not installed or the directory is not a Git repository:
- Check the `SPECIFY_FEATURE` environment variable as a fallback
- If set, validate that value against the naming patterns
- If not set, skip validation with a warning

View file

@ -0,0 +1,62 @@
# Git Branching Workflow Extension Configuration
# Copied to .specify/extensions/git/git-config.yml on install
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"
# Auto-commit before/after core commands.
# Set "default" to enable for all commands, then override per-command.
# Each key can be true/false. Message is customizable per-command.
auto_commit:
default: false
before_clarify:
enabled: false
message: "[Spec Kit] Save progress before clarification"
before_plan:
enabled: false
message: "[Spec Kit] Save progress before planning"
before_tasks:
enabled: false
message: "[Spec Kit] Save progress before task generation"
before_implement:
enabled: false
message: "[Spec Kit] Save progress before implementation"
before_checklist:
enabled: false
message: "[Spec Kit] Save progress before checklist"
before_analyze:
enabled: false
message: "[Spec Kit] Save progress before analysis"
before_taskstoissues:
enabled: false
message: "[Spec Kit] Save progress before issue sync"
after_constitution:
enabled: false
message: "[Spec Kit] Add project constitution"
after_specify:
enabled: false
message: "[Spec Kit] Add specification"
after_clarify:
enabled: false
message: "[Spec Kit] Clarify specification"
after_plan:
enabled: false
message: "[Spec Kit] Add implementation plan"
after_tasks:
enabled: false
message: "[Spec Kit] Add tasks"
after_implement:
enabled: false
message: "[Spec Kit] Implementation progress"
after_checklist:
enabled: false
message: "[Spec Kit] Add checklist"
after_analyze:
enabled: false
message: "[Spec Kit] Add analysis report"
after_taskstoissues:
enabled: false
message: "[Spec Kit] Sync tasks to issues"

View file

@ -0,0 +1,140 @@
schema_version: "1.0"
extension:
id: git
name: "Git Branching Workflow"
version: "1.0.0"
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
author: spec-kit-core
repository: https://github.com/github/spec-kit
license: MIT
requires:
speckit_version: ">=0.2.0"
tools:
- name: git
required: false
provides:
commands:
- name: speckit.git.feature
file: commands/speckit.git.feature.md
description: "Create a feature branch with sequential or timestamp numbering"
- name: speckit.git.validate
file: commands/speckit.git.validate.md
description: "Validate current branch follows feature branch naming conventions"
- name: speckit.git.remote
file: commands/speckit.git.remote.md
description: "Detect Git remote URL for GitHub integration"
- name: speckit.git.initialize
file: commands/speckit.git.initialize.md
description: "Initialize a Git repository with an initial commit"
- name: speckit.git.commit
file: commands/speckit.git.commit.md
description: "Auto-commit changes after a Spec Kit command completes"
config:
- name: "git-config.yml"
template: "config-template.yml"
description: "Git branching configuration"
required: false
hooks:
before_constitution:
command: speckit.git.initialize
optional: false
description: "Initialize Git repository before constitution setup"
before_specify:
command: speckit.git.feature
optional: false
description: "Create feature branch before specification"
before_clarify:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before clarification?"
description: "Auto-commit before spec clarification"
before_plan:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before planning?"
description: "Auto-commit before implementation planning"
before_tasks:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before task generation?"
description: "Auto-commit before task generation"
before_implement:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before implementation?"
description: "Auto-commit before implementation"
before_checklist:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before checklist?"
description: "Auto-commit before checklist generation"
before_analyze:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before analysis?"
description: "Auto-commit before analysis"
before_taskstoissues:
command: speckit.git.commit
optional: true
prompt: "Commit outstanding changes before issue sync?"
description: "Auto-commit before tasks-to-issues conversion"
after_constitution:
command: speckit.git.commit
optional: true
prompt: "Commit constitution changes?"
description: "Auto-commit after constitution update"
after_specify:
command: speckit.git.commit
optional: true
prompt: "Commit specification changes?"
description: "Auto-commit after specification"
after_clarify:
command: speckit.git.commit
optional: true
prompt: "Commit clarification changes?"
description: "Auto-commit after spec clarification"
after_plan:
command: speckit.git.commit
optional: true
prompt: "Commit plan changes?"
description: "Auto-commit after implementation planning"
after_tasks:
command: speckit.git.commit
optional: true
prompt: "Commit task changes?"
description: "Auto-commit after task generation"
after_implement:
command: speckit.git.commit
optional: true
prompt: "Commit implementation changes?"
description: "Auto-commit after implementation"
after_checklist:
command: speckit.git.commit
optional: true
prompt: "Commit checklist changes?"
description: "Auto-commit after checklist generation"
after_analyze:
command: speckit.git.commit
optional: true
prompt: "Commit analysis results?"
description: "Auto-commit after analysis"
after_taskstoissues:
command: speckit.git.commit
optional: true
prompt: "Commit after syncing issues?"
description: "Auto-commit after tasks-to-issues conversion"
tags:
- "git"
- "branching"
- "workflow"
config:
defaults:
branch_numbering: sequential
init_commit_message: "[Spec Kit] Initial commit"

View file

@ -0,0 +1,62 @@
# Git Branching Workflow Extension Configuration
# Copied to .specify/extensions/git/git-config.yml on install
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
branch_numbering: sequential
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"
# Auto-commit before/after core commands.
# Set "default" to enable for all commands, then override per-command.
# Each key can be true/false. Message is customizable per-command.
auto_commit:
default: false
before_clarify:
enabled: false
message: "[Spec Kit] Save progress before clarification"
before_plan:
enabled: false
message: "[Spec Kit] Save progress before planning"
before_tasks:
enabled: false
message: "[Spec Kit] Save progress before task generation"
before_implement:
enabled: false
message: "[Spec Kit] Save progress before implementation"
before_checklist:
enabled: false
message: "[Spec Kit] Save progress before checklist"
before_analyze:
enabled: false
message: "[Spec Kit] Save progress before analysis"
before_taskstoissues:
enabled: false
message: "[Spec Kit] Save progress before issue sync"
after_constitution:
enabled: false
message: "[Spec Kit] Add project constitution"
after_specify:
enabled: false
message: "[Spec Kit] Add specification"
after_clarify:
enabled: false
message: "[Spec Kit] Clarify specification"
after_plan:
enabled: false
message: "[Spec Kit] Add implementation plan"
after_tasks:
enabled: false
message: "[Spec Kit] Add tasks"
after_implement:
enabled: false
message: "[Spec Kit] Implementation progress"
after_checklist:
enabled: false
message: "[Spec Kit] Add checklist"
after_analyze:
enabled: false
message: "[Spec Kit] Add analysis report"
after_taskstoissues:
enabled: false
message: "[Spec Kit] Sync tasks to issues"

View file

@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Git extension: auto-commit.sh
# Automatically commit changes after a Spec Kit command completes.
# Checks per-command config keys in git-config.yml before committing.
#
# Usage: auto-commit.sh <event_name>
# e.g.: auto-commit.sh after_specify
set -e
EVENT_NAME="${1:-}"
if [ -z "$EVENT_NAME" ]; then
echo "Usage: $0 <event_name>" >&2
exit 1
fi
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_find_project_root() {
local dir="$1"
while [ "$dir" != "/" ]; do
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
cd "$REPO_ROOT"
# Check if git is available
if ! command -v git >/dev/null 2>&1; then
echo "[specify] Warning: Git not found; skipped auto-commit" >&2
exit 0
fi
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2
exit 0
fi
# Read per-command config from git-config.yml
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
_enabled=false
_commit_msg=""
if [ -f "$_config_file" ]; then
# Parse the auto_commit section for this event.
# Look for auto_commit.<event_name>.enabled and .message
# Also check auto_commit.default as fallback.
_in_auto_commit=false
_in_event=false
_default_enabled=false
while IFS= read -r _line; do
# Detect auto_commit: section
if echo "$_line" | grep -q '^auto_commit:'; then
_in_auto_commit=true
_in_event=false
continue
fi
# Exit auto_commit section on next top-level key
if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then
break
fi
if $_in_auto_commit; then
# Check default key
if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
[ "$_val" = "true" ] && _default_enabled=true
fi
# Detect our event subsection
if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then
_in_event=true
continue
fi
# Inside our event subsection
if $_in_event; then
# Exit on next sibling key (same indent level as event name)
if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then
_in_event=false
continue
fi
if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
[ "$_val" = "true" ] && _enabled=true
[ "$_val" = "false" ] && _enabled=false
fi
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
fi
fi
fi
done < "$_config_file"
# If event-specific key not found, use default
if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then
# Only use default if the event wasn't explicitly set to false
# Check if event section existed at all
if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then
_enabled=true
fi
fi
else
# No config file — auto-commit disabled by default
exit 0
fi
if [ "$_enabled" != "true" ]; then
exit 0
fi
# Check if there are changes to commit
if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
echo "[specify] No changes to commit after $EVENT_NAME" >&2
exit 0
fi
# Derive a human-readable command name from the event
# e.g., after_specify -> specify, before_plan -> plan
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after')
# Use custom message if configured, otherwise default
if [ -z "$_commit_msg" ]; then
_commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}"
fi
# Stage and commit
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
echo "[OK] Changes committed ${_phase} ${_command_name}" >&2

View file

@ -0,0 +1,453 @@
#!/usr/bin/env bash
# Git extension: create-new-feature.sh
# Adapted from core scripts/bash/create-new-feature.sh for extension layout.
# Sources common.sh from the project's installed scripts, falling back to
# git-common.sh for minimal git helpers.
set -e
JSON_MODE=false
DRY_RUN=false
ALLOW_EXISTING=false
SHORT_NAME=""
BRANCH_NUMBER=""
USE_TIMESTAMP=false
ARGS=()
i=1
while [ $i -le $# ]; do
arg="${!i}"
case "$arg" in
--json)
JSON_MODE=true
;;
--dry-run)
DRY_RUN=true
;;
--allow-existing-branch)
ALLOW_EXISTING=true
;;
--short-name)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --short-name requires a value' >&2
exit 1
fi
i=$((i + 1))
next_arg="${!i}"
if [[ "$next_arg" == --* ]]; then
echo 'Error: --short-name requires a value' >&2
exit 1
fi
SHORT_NAME="$next_arg"
;;
--number)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --number requires a value' >&2
exit 1
fi
i=$((i + 1))
next_arg="${!i}"
if [[ "$next_arg" == --* ]]; then
echo 'Error: --number requires a value' >&2
exit 1
fi
BRANCH_NUMBER="$next_arg"
if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
echo 'Error: --number must be a non-negative integer' >&2
exit 1
fi
;;
--timestamp)
USE_TIMESTAMP=true
;;
--help|-h)
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
echo ""
echo "Options:"
echo " --json Output in JSON format"
echo " --dry-run Compute branch name without creating the branch"
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message"
echo ""
echo "Environment variables:"
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
echo ""
echo "Examples:"
echo " $0 'Add user authentication system' --short-name 'user-auth'"
echo " $0 'Implement OAuth2 integration for API' --number 5"
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
echo " GIT_BRANCH_NAME=my-branch $0 'feature description'"
exit 0
;;
*)
ARGS+=("$arg")
;;
esac
i=$((i + 1))
done
FEATURE_DESCRIPTION="${ARGS[*]}"
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
exit 1
fi
# Trim whitespace and validate description is not empty
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
exit 1
fi
# Function to get highest number from specs directory
get_highest_from_specs() {
local specs_dir="$1"
local highest=0
if [ -d "$specs_dir" ]; then
for dir in "$specs_dir"/*; do
[ -d "$dir" ] || continue
dirname=$(basename "$dir")
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done
fi
echo "$highest"
}
# Function to get highest number from git branches
get_highest_from_branches() {
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
}
# Extract the highest sequential feature number from a list of ref names (one per line).
_extract_highest_number() {
local highest=0
while IFS= read -r name; do
[ -z "$name" ] && continue
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done
echo "$highest"
}
# Function to get highest number from remote branches without fetching (side-effect-free)
get_highest_from_remote_refs() {
local highest=0
for remote in $(git remote 2>/dev/null); do
local remote_highest
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
if [ "$remote_highest" -gt "$highest" ]; then
highest=$remote_highest
fi
done
echo "$highest"
}
# Function to check existing branches and return next available number.
check_existing_branches() {
local specs_dir="$1"
local skip_fetch="${2:-false}"
if [ "$skip_fetch" = true ]; then
local highest_remote=$(get_highest_from_remote_refs)
local highest_branch=$(get_highest_from_branches)
if [ "$highest_remote" -gt "$highest_branch" ]; then
highest_branch=$highest_remote
fi
else
git fetch --all --prune >/dev/null 2>&1 || true
local highest_branch=$(get_highest_from_branches)
fi
local highest_spec=$(get_highest_from_specs "$specs_dir")
local max_num=$highest_branch
if [ "$highest_spec" -gt "$max_num" ]; then
max_num=$highest_spec
fi
echo $((max_num + 1))
}
# Function to clean and format a branch name
clean_branch_name() {
local name="$1"
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}
# ---------------------------------------------------------------------------
# Source common.sh for resolve_template, json_escape, get_repo_root, has_git.
#
# Search locations in priority order:
# 1. .specify/scripts/bash/common.sh under the project root (installed project)
# 2. scripts/bash/common.sh under the project root (source checkout fallback)
# 3. git-common.sh next to this script (minimal fallback — lacks resolve_template)
# ---------------------------------------------------------------------------
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Find project root by walking up from the script location
_find_project_root() {
local dir="$1"
while [ "$dir" != "/" ]; do
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
_common_loaded=false
_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true
if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then
source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh"
_common_loaded=true
elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then
source "$_PROJECT_ROOT/scripts/bash/common.sh"
_common_loaded=true
elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then
source "$SCRIPT_DIR/git-common.sh"
_common_loaded=true
fi
if [ "$_common_loaded" != "true" ]; then
echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2
exit 1
fi
# Resolve repository root
if type get_repo_root >/dev/null 2>&1; then
REPO_ROOT=$(get_repo_root)
elif git rev-parse --show-toplevel >/dev/null 2>&1; then
REPO_ROOT=$(git rev-parse --show-toplevel)
elif [ -n "$_PROJECT_ROOT" ]; then
REPO_ROOT="$_PROJECT_ROOT"
else
echo "Error: Could not determine repository root." >&2
exit 1
fi
# Check if git is available at this repo root
if type has_git >/dev/null 2>&1; then
if has_git "$REPO_ROOT"; then
HAS_GIT=true
else
HAS_GIT=false
fi
elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
HAS_GIT=true
else
HAS_GIT=false
fi
cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs"
# Function to generate branch name with stop word filtering
generate_branch_name() {
local description="$1"
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
local meaningful_words=()
for word in $clean_name; do
[ -z "$word" ] && continue
if ! echo "$word" | grep -qiE "$stop_words"; then
if [ ${#word} -ge 3 ]; then
meaningful_words+=("$word")
elif echo "$description" | grep -qw -- "${word^^}"; then
meaningful_words+=("$word")
fi
fi
done
if [ ${#meaningful_words[@]} -gt 0 ]; then
local max_words=3
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
local result=""
local count=0
for word in "${meaningful_words[@]}"; do
if [ $count -ge $max_words ]; then break; fi
if [ -n "$result" ]; then result="$result-"; fi
result="$result$word"
count=$((count + 1))
done
echo "$result"
else
local cleaned=$(clean_branch_name "$description")
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
fi
}
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
BRANCH_NAME="$GIT_BRANCH_NAME"
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
else
FEATURE_NUM="$BRANCH_NAME"
BRANCH_SUFFIX="$BRANCH_NAME"
fi
else
# Generate branch name
if [ -n "$SHORT_NAME" ]; then
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
else
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
fi
# Warn if --number and --timestamp are both specified
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
BRANCH_NUMBER=""
fi
# Determine branch prefix
if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else
if [ -z "$BRANCH_NUMBER" ]; then
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
elif [ "$DRY_RUN" = true ]; then
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
elif [ "$HAS_GIT" = true ]; then
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
fi
fi
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
fi
fi
# GitHub enforces a 244-byte limit on branch names
MAX_BRANCH_LENGTH=244
_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; }
BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
exit 1
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
fi
if [ "$DRY_RUN" != true ]; then
if [ "$HAS_GIT" = true ]; then
branch_create_error=""
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
if git branch --list "$BRANCH_NAME" | grep -q .; then
if [ "$ALLOW_EXISTING" = true ]; then
if [ "$current_branch" = "$BRANCH_NAME" ]; then
:
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
if [ -n "$switch_branch_error" ]; then
>&2 printf '%s\n' "$switch_branch_error"
fi
exit 1
fi
elif [ "$USE_TIMESTAMP" = true ]; then
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
exit 1
else
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
exit 1
fi
else
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
if [ -n "$branch_create_error" ]; then
>&2 printf '%s\n' "$branch_create_error"
else
>&2 echo "Please check your git configuration and try again."
fi
exit 1
fi
fi
else
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
fi
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
fi
if $JSON_MODE; then
if command -v jq >/dev/null 2>&1; then
if [ "$DRY_RUN" = true ]; then
jq -cn \
--arg branch_name "$BRANCH_NAME" \
--arg feature_num "$FEATURE_NUM" \
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}'
else
jq -cn \
--arg branch_name "$BRANCH_NAME" \
--arg feature_num "$FEATURE_NUM" \
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}'
fi
else
if type json_escape >/dev/null 2>&1; then
_je_branch=$(json_escape "$BRANCH_NAME")
_je_num=$(json_escape "$FEATURE_NUM")
else
_je_branch="$BRANCH_NAME"
_je_num="$FEATURE_NUM"
fi
if [ "$DRY_RUN" = true ]; then
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num"
else
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num"
fi
fi
else
echo "BRANCH_NAME: $BRANCH_NAME"
echo "FEATURE_NUM: $FEATURE_NUM"
if [ "$DRY_RUN" != true ]; then
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
fi
fi

View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Git-specific common functions for the git extension.
# Extracted from scripts/bash/common.sh — contains only git-specific
# branch validation and detection logic.
# Check if we have git available at the repo root
has_git() {
local repo_root="${1:-$(pwd)}"
{ [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \
command -v git >/dev/null 2>&1 && \
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
}
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
spec_kit_effective_branch_name() {
local raw="$1"
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
printf '%s\n' "${BASH_REMATCH[2]}"
else
printf '%s\n' "$raw"
fi
}
# Validate that a branch name matches the expected feature branch pattern.
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
check_feature_branch() {
local raw="$1"
local has_git_repo="$2"
# For non-git repos, we can't enforce branch naming but still provide output
if [[ "$has_git_repo" != "true" ]]; then
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
return 0
fi
local branch
branch=$(spec_kit_effective_branch_name "$raw")
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
local is_sequential=false
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
is_sequential=true
fi
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
return 1
fi
return 0
}

View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Git extension: initialize-repo.sh
# Initialize a Git repository with an initial commit.
# Customizable — replace this script to add .gitignore templates,
# default branch config, git-flow, LFS, signing, etc.
set -e
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Find project root
_find_project_root() {
local dir="$1"
while [ "$dir" != "/" ]; do
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
cd "$REPO_ROOT"
# Read commit message from extension config, fall back to default
COMMIT_MSG="[Spec Kit] Initial commit"
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
if [ -f "$_config_file" ]; then
_msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
if [ -n "$_msg" ]; then
COMMIT_MSG="$_msg"
fi
fi
# Check if git is available
if ! command -v git >/dev/null 2>&1; then
echo "[specify] Warning: Git not found; skipped repository initialization" >&2
exit 0
fi
# Check if already a git repo
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "[specify] Git repository already initialized; skipping" >&2
exit 0
fi
# Initialize
_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; }
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
echo "✓ Git repository initialized" >&2

View file

@ -0,0 +1,169 @@
#!/usr/bin/env pwsh
# Git extension: auto-commit.ps1
# Automatically commit changes after a Spec Kit command completes.
# Checks per-command config keys in git-config.yml before committing.
#
# Usage: auto-commit.ps1 <event_name>
# e.g.: auto-commit.ps1 after_specify
param(
[Parameter(Position = 0, Mandatory = $true)]
[string]$EventName
)
$ErrorActionPreference = 'Stop'
function Find-ProjectRoot {
param([string]$StartDir)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in @('.specify', '.git')) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) { return $null }
$current = $parent
}
}
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
if (-not $repoRoot) { $repoRoot = Get-Location }
Set-Location $repoRoot
# Check if git is available
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Write-Warning "[specify] Warning: Git not found; skipped auto-commit"
exit 0
}
# Temporarily relax ErrorActionPreference so git stderr warnings
# (e.g. CRLF notices on Windows) do not become terminating errors.
$savedEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
git rev-parse --is-inside-work-tree 2>$null | Out-Null
$isRepo = $LASTEXITCODE -eq 0
} finally {
$ErrorActionPreference = $savedEAP
}
if (-not $isRepo) {
Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit"
exit 0
}
# Read per-command config from git-config.yml
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
$enabled = $false
$commitMsg = ""
if (Test-Path $configFile) {
# Parse YAML to find auto_commit section
$inAutoCommit = $false
$inEvent = $false
$defaultEnabled = $false
foreach ($line in Get-Content $configFile) {
# Detect auto_commit: section
if ($line -match '^auto_commit:') {
$inAutoCommit = $true
$inEvent = $false
continue
}
# Exit auto_commit section on next top-level key
if ($inAutoCommit -and $line -match '^[a-z]') {
break
}
if ($inAutoCommit) {
# Check default key
if ($line -match '^\s+default:\s*(.+)$') {
$val = $matches[1].Trim().ToLower()
if ($val -eq 'true') { $defaultEnabled = $true }
}
# Detect our event subsection
if ($line -match "^\s+${EventName}:") {
$inEvent = $true
continue
}
# Inside our event subsection
if ($inEvent) {
# Exit on next sibling key (2-space indent, not 4+)
if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') {
$inEvent = $false
continue
}
if ($line -match '\s+enabled:\s*(.+)$') {
$val = $matches[1].Trim().ToLower()
if ($val -eq 'true') { $enabled = $true }
if ($val -eq 'false') { $enabled = $false }
}
if ($line -match '\s+message:\s*(.+)$') {
$commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
}
}
}
}
# If event-specific key not found, use default
if (-not $enabled -and $defaultEnabled) {
$hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet
if (-not $hasEventKey) {
$enabled = $true
}
}
} else {
# No config file -- auto-commit disabled by default
exit 0
}
if (-not $enabled) {
exit 0
}
# Check if there are changes to commit
# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate.
$savedEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE
git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE
$untracked = git ls-files --others --exclude-standard 2>$null
} finally {
$ErrorActionPreference = $savedEAP
}
if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray
exit 0
}
# Derive a human-readable command name from the event
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
# Use custom message if configured, otherwise default
if (-not $commitMsg) {
$commitMsg = "[Spec Kit] Auto-commit $phase $commandName"
}
# Stage and commit
# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate,
# while still allowing redirected error output to be captured for diagnostics.
$savedEAP = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$out = git add . 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
$out = git commit -q -m $commitMsg 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
} catch {
Write-Warning "[specify] Error: $_"
exit 1
} finally {
$ErrorActionPreference = $savedEAP
}
Write-Host "[OK] Changes committed $phase $commandName"

View file

@ -0,0 +1,403 @@
#!/usr/bin/env pwsh
# Git extension: create-new-feature.ps1
# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
# Sources common.ps1 from the project's installed scripts, falling back to
# git-common.ps1 for minimal git helpers.
[CmdletBinding()]
param(
[switch]$Json,
[switch]$AllowExistingBranch,
[switch]$DryRun,
[string]$ShortName,
[Parameter()]
[long]$Number = 0,
[switch]$Timestamp,
[switch]$Help,
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
[string[]]$FeatureDescription
)
$ErrorActionPreference = 'Stop'
if ($Help) {
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Host ""
Write-Host "Options:"
Write-Host " -Json Output in JSON format"
Write-Host " -DryRun Compute branch name without creating the branch"
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
Write-Host " -Help Show this help message"
Write-Host ""
Write-Host "Environment variables:"
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
Write-Host ""
exit 0
}
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
exit 1
}
$featureDesc = ($FeatureDescription -join ' ').Trim()
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
exit 1
}
function Get-HighestNumberFromSpecs {
param([string]$SpecsDir)
[long]$highest = 0
if (Test-Path $SpecsDir) {
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
}
}
}
}
return $highest
}
function Get-HighestNumberFromNames {
param([string[]]$Names)
[long]$highest = 0
foreach ($name in $Names) {
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
}
}
}
return $highest
}
function Get-HighestNumberFromBranches {
param()
try {
$branches = git branch -a 2>$null
if ($LASTEXITCODE -eq 0 -and $branches) {
$cleanNames = $branches | ForEach-Object {
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
}
return Get-HighestNumberFromNames -Names $cleanNames
}
} catch {
Write-Verbose "Could not check Git branches: $_"
}
return 0
}
function Get-HighestNumberFromRemoteRefs {
[long]$highest = 0
try {
$remotes = git remote 2>$null
if ($remotes) {
foreach ($remote in $remotes) {
$env:GIT_TERMINAL_PROMPT = '0'
$refs = git ls-remote --heads $remote 2>$null
$env:GIT_TERMINAL_PROMPT = $null
if ($LASTEXITCODE -eq 0 -and $refs) {
$refNames = $refs | ForEach-Object {
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
} | Where-Object { $_ }
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
}
}
}
} catch {
Write-Verbose "Could not query remote refs: $_"
}
return $highest
}
function Get-NextBranchNumber {
param(
[string]$SpecsDir,
[switch]$SkipFetch
)
if ($SkipFetch) {
$highestBranch = Get-HighestNumberFromBranches
$highestRemote = Get-HighestNumberFromRemoteRefs
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
} else {
try {
git fetch --all --prune 2>$null | Out-Null
} catch { }
$highestBranch = Get-HighestNumberFromBranches
}
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
$maxNum = [Math]::Max($highestBranch, $highestSpec)
return $maxNum + 1
}
function ConvertTo-CleanBranchName {
param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
# ---------------------------------------------------------------------------
# Source common.ps1 from the project's installed scripts.
# Search locations in priority order:
# 1. .specify/scripts/powershell/common.ps1 under the project root
# 2. scripts/powershell/common.ps1 under the project root (source checkout)
# 3. git-common.ps1 next to this script (minimal fallback)
# ---------------------------------------------------------------------------
function Find-ProjectRoot {
param([string]$StartDir)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in @('.specify', '.git')) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) { return $null }
$current = $parent
}
}
$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot
$commonLoaded = $false
if ($projectRoot) {
$candidates = @(
(Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"),
(Join-Path $projectRoot "scripts/powershell/common.ps1")
)
foreach ($candidate in $candidates) {
if (Test-Path $candidate) {
. $candidate
$commonLoaded = $true
break
}
}
}
if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) {
. "$PSScriptRoot/git-common.ps1"
$commonLoaded = $true
}
if (-not $commonLoaded) {
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
}
# Resolve repository root
if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
$repoRoot = Get-RepoRoot
} elseif ($projectRoot) {
$repoRoot = $projectRoot
} else {
throw "Could not determine repository root."
}
# Check if git is available
if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
# Call without parameters for compatibility with core common.ps1 (no -RepoRoot param)
# and git-common.ps1 (has -RepoRoot param with default).
$hasGit = Test-HasGit
} else {
try {
git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
$hasGit = ($LASTEXITCODE -eq 0)
} catch {
$hasGit = $false
}
}
Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs'
function Get-BranchName {
param([string]$Description)
$stopWords = @(
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
'want', 'need', 'add', 'get', 'set'
)
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
$words = $cleanName -split '\s+' | Where-Object { $_ }
$meaningfulWords = @()
foreach ($word in $words) {
if ($stopWords -contains $word) { continue }
if ($word.Length -ge 3) {
$meaningfulWords += $word
} elseif ($Description -match "\b$($word.ToUpper())\b") {
$meaningfulWords += $word
}
}
if ($meaningfulWords.Count -gt 0) {
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
return $result
} else {
$result = ConvertTo-CleanBranchName -Name $Description
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
return [string]::Join('-', $fallbackWords)
}
}
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
if ($env:GIT_BRANCH_NAME) {
$branchName = $env:GIT_BRANCH_NAME
# Check 244-byte limit (UTF-8) for override names
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
if ($branchNameUtf8ByteCount -gt 244) {
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
}
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
if ($branchName -match '^(\d{8}-\d{6})-') {
$featureNum = $matches[1]
} elseif ($branchName -match '^(\d+)-') {
$featureNum = $matches[1]
} else {
$featureNum = $branchName
}
} else {
if ($ShortName) {
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
} else {
$branchSuffix = Get-BranchName -Description $featureDesc
}
if ($Timestamp -and $Number -ne 0) {
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
$Number = 0
}
if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = "$featureNum-$branchSuffix"
} else {
if ($Number -eq 0) {
if ($DryRun -and $hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
} elseif ($DryRun) {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
} elseif ($hasGit) {
$Number = Get-NextBranchNumber -SpecsDir $specsDir
} else {
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
}
}
$featureNum = ('{0:000}' -f $Number)
$branchName = "$featureNum-$branchSuffix"
}
}
$maxBranchLength = 244
if ($branchName.Length -gt $maxBranchLength) {
$prefixLength = $featureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$originalBranchName = $branchName
$branchName = "$featureNum-$truncatedSuffix"
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
}
if (-not $DryRun) {
if ($hasGit) {
$branchCreated = $false
$branchCreateError = ''
try {
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
if ($LASTEXITCODE -eq 0) {
$branchCreated = $true
}
} catch {
$branchCreateError = $_.Exception.Message
}
if (-not $branchCreated) {
$currentBranch = ''
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
$existingBranch = git branch --list $branchName 2>$null
if ($existingBranch) {
if ($AllowExistingBranch) {
if ($currentBranch -eq $branchName) {
# Already on the target branch
} else {
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) {
if ($switchBranchError) {
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
} else {
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
}
exit 1
}
}
} elseif ($Timestamp) {
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
exit 1
} else {
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
exit 1
}
} else {
if ($branchCreateError) {
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
} else {
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
}
exit 1
}
}
} else {
if ($Json) {
[Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName")
} else {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
}
}
$env:SPECIFY_FEATURE = $branchName
}
if ($Json) {
$obj = [PSCustomObject]@{
BRANCH_NAME = $branchName
FEATURE_NUM = $featureNum
HAS_GIT = $hasGit
}
if ($DryRun) {
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
}
$obj | ConvertTo-Json -Compress
} else {
Write-Output "BRANCH_NAME: $branchName"
Write-Output "FEATURE_NUM: $featureNum"
Write-Output "HAS_GIT: $hasGit"
if (-not $DryRun) {
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
}
}

View file

@ -0,0 +1,51 @@
#!/usr/bin/env pwsh
# Git-specific common functions for the git extension.
# Extracted from scripts/powershell/common.ps1 -- contains only git-specific
# branch validation and detection logic.
function Test-HasGit {
param([string]$RepoRoot = (Get-Location))
try {
if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false }
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false }
git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
function Get-SpecKitEffectiveBranchName {
param([string]$Branch)
if ($Branch -match '^([^/]+)/([^/]+)$') {
return $Matches[2]
}
return $Branch
}
function Test-FeatureBranch {
param(
[string]$Branch,
[bool]$HasGit = $true
)
# For non-git repos, we can't enforce branch naming but still provide output
if (-not $HasGit) {
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
return $true
}
$raw = $Branch
$Branch = Get-SpecKitEffectiveBranchName $raw
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
return $false
}
return $true
}

View file

@ -0,0 +1,69 @@
#!/usr/bin/env pwsh
# Git extension: initialize-repo.ps1
# Initialize a Git repository with an initial commit.
# Customizable -- replace this script to add .gitignore templates,
# default branch config, git-flow, LFS, signing, etc.
$ErrorActionPreference = 'Stop'
# Find project root
function Find-ProjectRoot {
param([string]$StartDir)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in @('.specify', '.git')) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) { return $null }
$current = $parent
}
}
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
if (-not $repoRoot) { $repoRoot = Get-Location }
Set-Location $repoRoot
# Read commit message from extension config, fall back to default
$commitMsg = "[Spec Kit] Initial commit"
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
if (Test-Path $configFile) {
foreach ($line in Get-Content $configFile) {
if ($line -match '^init_commit_message:\s*(.+)$') {
$val = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
if ($val) { $commitMsg = $val }
break
}
}
}
# Check if git is available
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Write-Warning "[specify] Warning: Git not found; skipped repository initialization"
exit 0
}
# Check if already a git repo
try {
git rev-parse --is-inside-work-tree 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Warning "[specify] Git repository already initialized; skipping"
exit 0
}
} catch { }
# Initialize
try {
$out = git init -q 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" }
$out = git add . 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
$out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
} catch {
Write-Warning "[specify] Error: $_"
exit 1
}
Write-Host "[OK] Git repository initialized"

3
.specify/feature.json Normal file
View file

@ -0,0 +1,3 @@
{
"feature_directory": "specs/003-desk-auth-rbac"
}

View file

@ -0,0 +1,9 @@
{
"ai": "cursor-agent",
"ai_skills": true,
"branch_numbering": "sequential",
"here": true,
"integration": "cursor-agent",
"script": "sh",
"speckit_version": "0.9.6.dev0"
}

15
.specify/integration.json Normal file
View file

@ -0,0 +1,15 @@
{
"version": "0.9.6.dev0",
"integration_state_schema": 1,
"installed_integrations": [
"cursor-agent"
],
"integration_settings": {
"cursor-agent": {
"script": "sh",
"invoke_separator": "-"
}
},
"integration": "cursor-agent",
"default_integration": "cursor-agent"
}

View file

@ -0,0 +1,16 @@
{
"integration": "cursor-agent",
"version": "0.9.6.dev0",
"installed_at": "2026-06-08T16:25:46.544238+00:00",
"files": {
".cursor/skills/speckit-analyze/SKILL.md": "753f1d49d830abc130132ad2864c780ea61fd57bbc71aa9888be24fdf0774800",
".cursor/skills/speckit-clarify/SKILL.md": "08e643cb56c88adf1f4b28821d490360186f6bc0dfb1f21a059e16e4b8e89b91",
".cursor/skills/speckit-constitution/SKILL.md": "e2cbe859958c5a05be52a44d63821e6a84d39f3d37acc05b550cc7ad85da0dab",
".cursor/skills/speckit-implement/SKILL.md": "796ab9a7f04281fee7d390087e89438f4215cbe2396a8a0118dafd12c0268894",
".cursor/skills/speckit-plan/SKILL.md": "728f1872273e4f6fbd573308a9f86f3a858a3aaced9a5bb19ec6619acff4c2cc",
".cursor/skills/speckit-checklist/SKILL.md": "734393e5698b390283db49135e1140d6ad529b65eae439bb0a53bc5acab2b529",
".cursor/skills/speckit-specify/SKILL.md": "e74c7b705bebbdf457d0b01e928a4d4f25bd3f77b8c650a2ef3c463e706550ec",
".cursor/skills/speckit-tasks/SKILL.md": "bb461317a2b17eda72250202197a8307e519a24cd22758d3091389c70d869af1",
".cursor/skills/speckit-taskstoissues/SKILL.md": "a3efcf92cf532420c10abf7b9253204ade34b7329888c28e271fa3da7750c584"
}
}

View file

@ -0,0 +1,17 @@
{
"integration": "speckit",
"version": "0.9.6.dev0",
"installed_at": "2026-06-08T16:25:46.562762+00:00",
"files": {
".specify/scripts/bash/setup-tasks.sh": "7aeee15192a5ab3ba9ff3c3ae450d9994043bf0493c1eabc840da72a9742fc87",
".specify/scripts/bash/setup-plan.sh": "b23cca3d769a217ab812a6059adb549622471f6893af234cf98ca2019ac4e1a1",
".specify/scripts/bash/check-prerequisites.sh": "f4541a00257f035aa55a9fede6d964e51e6851c3dc2f81d0a6f367db18944765",
".specify/scripts/bash/common.sh": "1b52fdc114424b83784d59477256e1854c23ee3135273625904eb0231cc0c37e",
".specify/scripts/bash/create-new-feature.sh": "bcf4964ca0c6c78717bb42d9e66b8c7e5ee82779cd96afc5aa7b08b75abe5790",
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
".specify/templates/plan-template.md": "cc7f7979cf8d8836ec26492785affd80791d3422a2b745062ec695be8c985ef7",
".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e",
".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842",
".specify/templates/checklist-template.md": "c37695297e5d3153d64f82c21223509940b13932046c7961c42d1d669516130c"
}
}

View file

@ -0,0 +1,159 @@
# Ligbox Infrastructure Constitution
Constituição de governança para toda a infraestrutura Ligbox / Ibytera no cluster Proxmox `big1`.
Aplica-se a VMs, CTs, rede, segurança, deploys e integrações (mail, ops, portal).
---
## Core Principles
### I. Network Segmentation — vmbr1 como LAN L2 pura
A rede interna das VMs (`10.10.10.0/24`) deve ser servida por **vmbr1**, bridge **sem IPv4 no host Proxmox**:
- `bridge-ports none` — isolada do hardware
- Host **não** actua como gateway na vmbr1
- Gateway das VMs: **pfSense LAN** (`10.10.10.1`)
- Documentação de referência: `docs/network/INTERFACES_PROXMOX.md`
**Drift conhecido (2026-06-08):** VMs operam em `vmbr4000` (Hetzner vSwitch, host `10.10.10.254`). Migração para vmbr1 requer plano aprovado — não improvisar.
### II. Proxmox Interfaces — alteração tripla obrigatória
O ficheiro `/etc/network/interfaces` do host Proxmox é **crítico e imutável sem aprovação**:
1. Descrever intenção real e diff completo antes de qualquer alteração
2. Obter confirmação explícita do Roger **três vezes**
3. Verificar iptables antes e depois; limpar duplicatas; nunca `ifreload` múltiplo sem limpeza
Ordem das regras importa (FIFO). Destino WAN pfSense = `10.0.0.2`, **nunca** LAN `10.10.10.1` em DNAT.
### III. Anti-Scan Hetzner — regras obrigatórias (NON-NEGOTIABLE)
Quatro linhas de proteção FORWARD devem estar activas na secção vmbr1:
```
post-up iptables -I FORWARD -s 10.10.10.0/24 ! -d 10.10.10.0/24 -j DROP
post-up iptables -I FORWARD -s 10.10.10.0/24 ! -d 10.10.10.0/24 -p udp --dport 53 -j ACCEPT
```
**Proibido:** `POSTROUTING MASQUERADE` genérico para `10.10.10.0/24` (causa abuse reports).
Verificação obrigatória: após qualquer alteração de rede e **diariamente**.
### IV. Separation of Concerns — Mail vs Ops
| VM | Papel | Restrição |
|----|-------|-----------|
| **112** | Carbonio mail + portal onboard | Não misturar workloads de ops/monitoring |
| **113** | PMG mail gateway | Filtro SMTP apenas |
| **122** | Ligbox Ops Platform | API, desk, workers — **sem mail stack** |
Comunicação entre 112 e 122 via API/webhook na LAN (`10.10.10.x`), nunca exposição directa à internet.
### V. Security Baseline — toda VM exposta
Toda VM/CT com SSH (LAN ou WAN) deve ter:
- **fail2ban** activo com jail `sshd` (bantime ≥ 3600s, maxretry ≤ 5)
- `PasswordAuthentication` verificado com `sshd -T` (não confiar só no ficheiro principal)
- Ficheiros `sshd_config.d/*.conf` verificados (sobrescrevem config principal)
- Portas de serviço bindadas à LAN quando possível (`10.10.10.x`, não `0.0.0.0`)
### VI. pfSense API — acesso validado
- URL produção: `https://firewall.itecnologys.com/api/v2/` (via Traefik)
- Fallback directo: `https://10.10.10.1:10443/api/v2/`
- Autenticação: **Basic Auth** (`-u user:password`), nunca headers customizados
- Após alterações NAT/firewall: `POST /api/v2/firewall/apply`
- DNAT externo → pfSense WAN (`10.0.0.2`), portas SSH VMs via pfSense (25012522)
### VII. Spec-Driven Development — Spec Kit obrigatório
Todo trabalho de feature segue o fluxo Spec Kit **antes** de implementação:
```
/speckit.constitution → /speckit.specify → /speckit.clarify → /speckit.checklist
→ /speckit.plan → /speckit.tasks → /speckit.analyze → /speckit.implement
```
- Constitution (este ficheiro) precede qualquer feature
- Specs em `specs/` com branch `NNN-nome-feature`
- Não implementar sem `plan.md` + `tasks.md` validados
- Backlog livre migra para specs numeradas
### VIII. Documentation First — Obsidian + repo
- Inventário VMs: `obsidian-infra/docs/TABELA_VMS_*.md` — actualizar após cada VM nova
- Decisões arquitecturais: `docs/decisions/DECISAO_*.md`
- Chat bruto / sessões: `obsidian-infra/` (vault principal)
- Código deployável: `/opt/<projeto>/` na VM + sync para `workspace/projects/`
### IX. Simplicity — YAGNI no MVP
- Preferir SQLite a Postgres para ops MVP (VM122)
- Docker compose mínimo (api + worker + redis + frontend)
- Adiar: SOC, Wazuh centralizado, 7 agents, Postgres cluster
- Cada feature justifica complexidade adicional
---
## Security Requirements
| Requisito | Implementação |
|-----------|---------------|
| SSH externo | pfSense NAT `95.216.14.146:PORTA` → VM:22 |
| SSH interno | `root@10.10.10.x` a partir do host ou LAN |
| Webhook secrets | Rotação obrigatória em produção (não usar defaults dev) |
| Credenciais | Nunca commitar em git; usar `.env` local |
| Cloudflare API | Tokens com scope mínimo (zone DNS, não account-wide) |
| Tailscale/scanning | Monitorar regras FORWARD; bloquear vazamento LAN→WAN |
---
## Infrastructure Inventory (referência rápida)
| Componente | IP / Porta | Notas |
|------------|-----------|-------|
| Proxmox host | 95.216.14.162 | vmbr0 |
| pfSense LAN | 10.10.10.1 | VM100, gateway VMs |
| pfSense WAN | 10.0.0.2 | Destino DNAT |
| VM112 mail | 10.10.10.112:2512 | Carbonio + portal |
| VM113 PMG | 10.10.10.113:2513 | Mail gateway |
| VM122 ops | 10.10.10.122:2522 | Ligbox Ops MVP |
| CT119 qtm | 10.10.10.119 | Não migrar — reservado |
| Traefik | CT114 / VM105 | `firewall.itecnologys.com` |
---
## Development Workflow
1. **Constitution** — verificar compliance antes de iniciar feature
2. **Specify** — requisitos (o quê / porquê, sem tech stack)
3. **Clarify + Checklist** — obrigatório para produção
4. **Plan + Tasks** — arquitectura e passos executáveis
5. **Analyze** — consistência cross-artifact antes de implementar
6. **Implement** — código + deploy + verificação
7. **Validate** — testes, fail2ban, iptables, health endpoints
8. **Document** — actualizar inventário VMs e decisões
### Quality gates (bloqueantes)
- [ ] Constitution compliance verificada
- [ ] fail2ban activo na VM alvo
- [ ] Sem alteração não aprovada em `/etc/network/interfaces`
- [ ] Regras anti-scan Hetzner verificadas
- [ ] Health check da feature OK
- [ ] Inventário VMs actualizado
---
## Governance
- Esta constitution **supera** backlog informal, prompts ad-hoc e decisões não documentadas
- Emendas requerem: proposta escrita, aprovação Roger, bump de versão, data de ratificação
- Toda PR / sessão de implementação deve verificar compliance com esta constitution
- Drift de infra (ex.: vmbr1 com IP, VMs em vmbr4000) deve ser documentado em `docs/network/` até resolução
- Complexidade adicional (novo serviço, nova VM, nova bridge) requer spec dedicada — não improvisar
**Version**: 1.0.0 | **Ratified**: 2026-06-08 | **Last Amended**: 2026-06-08

View file

@ -0,0 +1,192 @@
#!/usr/bin/env bash
# Consolidated prerequisite checking script
#
# This script provides unified prerequisite checking for Spec-Driven Development workflow.
# It replaces the functionality previously spread across multiple scripts.
#
# Usage: ./check-prerequisites.sh [OPTIONS]
#
# OPTIONS:
# --json Output in JSON format
# --require-tasks Require tasks.md to exist (for implementation phase)
# --include-tasks Include tasks.md in AVAILABLE_DOCS list
# --paths-only Only output path variables (no validation)
# --help, -h Show help message
#
# OUTPUTS:
# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]}
# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md
# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc.
set -e
# Parse command line arguments
JSON_MODE=false
REQUIRE_TASKS=false
INCLUDE_TASKS=false
PATHS_ONLY=false
for arg in "$@"; do
case "$arg" in
--json)
JSON_MODE=true
;;
--require-tasks)
REQUIRE_TASKS=true
;;
--include-tasks)
INCLUDE_TASKS=true
;;
--paths-only)
PATHS_ONLY=true
;;
--help|-h)
cat << 'EOF'
Usage: check-prerequisites.sh [OPTIONS]
Consolidated prerequisite checking for Spec-Driven Development workflow.
OPTIONS:
--json Output in JSON format
--require-tasks Require tasks.md to exist (for implementation phase)
--include-tasks Include tasks.md in AVAILABLE_DOCS list
--paths-only Only output path variables (no prerequisite validation)
--help, -h Show this help message
EXAMPLES:
# Check task prerequisites (plan.md required)
./check-prerequisites.sh --json
# Check implementation prerequisites (plan.md + tasks.md required)
./check-prerequisites.sh --json --require-tasks --include-tasks
# Get feature paths only (no validation)
./check-prerequisites.sh --paths-only
EOF
exit 0
;;
*)
echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2
exit 1
;;
esac
done
# Source common functions
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
# Get feature paths
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
eval "$_paths_output"
unset _paths_output
# If paths-only mode, output paths and exit (no validation)
if $PATHS_ONLY; then
if $JSON_MODE; then
# Minimal JSON paths payload (no validation performed)
if has_jq; then
jq -cn \
--arg repo_root "$REPO_ROOT" \
--arg branch "$CURRENT_BRANCH" \
--arg feature_dir "$FEATURE_DIR" \
--arg feature_spec "$FEATURE_SPEC" \
--arg impl_plan "$IMPL_PLAN" \
--arg tasks "$TASKS" \
'{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}'
else
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \
"$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")"
fi
else
echo "REPO_ROOT: $REPO_ROOT"
echo "BRANCH: $CURRENT_BRANCH"
echo "FEATURE_DIR: $FEATURE_DIR"
echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN"
echo "TASKS: $TASKS"
fi
exit 0
fi
# Validate branch name
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
# Validate required directories and files
if [[ ! -d "$FEATURE_DIR" ]]; then
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
echo "Run /speckit-specify first to create the feature structure." >&2
exit 1
fi
if [[ ! -f "$IMPL_PLAN" ]]; then
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
echo "Run /speckit-plan first to create the implementation plan." >&2
exit 1
fi
# Check for tasks.md if required
if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
echo "Run /speckit-tasks first to create the task list." >&2
exit 1
fi
# Build list of available documents
docs=()
# Always check these optional docs
[[ -f "$RESEARCH" ]] && docs+=("research.md")
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
# Check contracts directory (only if it exists and has files)
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
docs+=("contracts/")
fi
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
# Include tasks.md if requested and it exists
if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then
docs+=("tasks.md")
fi
# Output results
if $JSON_MODE; then
# Build JSON array of documents
if has_jq; then
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
else
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
fi
jq -cn \
--arg feature_dir "$FEATURE_DIR" \
--argjson docs "$json_docs" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}'
else
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
else
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
json_docs="[${json_docs%,}]"
fi
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
fi
else
# Text output
echo "FEATURE_DIR:$FEATURE_DIR"
echo "AVAILABLE_DOCS:"
# Show status of each potential document
check_file "$RESEARCH" "research.md"
check_file "$DATA_MODEL" "data-model.md"
check_dir "$CONTRACTS_DIR" "contracts/"
check_file "$QUICKSTART" "quickstart.md"
if $INCLUDE_TASKS; then
check_file "$TASKS" "tasks.md"
fi
fi

721
.specify/scripts/bash/common.sh Executable file
View file

@ -0,0 +1,721 @@
#!/usr/bin/env bash
# Common functions and variables for all scripts
# Find repository root by searching upward for .specify directory
# This is the primary marker for spec-kit projects
find_specify_root() {
local dir="${1:-$(pwd)}"
# Normalize to absolute path to prevent infinite loop with relative paths
# Use -- to handle paths starting with - (e.g., -P, -L)
dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1
local prev_dir=""
while true; do
if [ -d "$dir/.specify" ]; then
echo "$dir"
return 0
fi
# Stop if we've reached filesystem root or dirname stops changing
if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then
break
fi
prev_dir="$dir"
dir="$(dirname "$dir")"
done
return 1
}
# Get repository root, prioritizing .specify directory over git
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
get_repo_root() {
# First, look for .specify directory (spec-kit's own marker)
local specify_root
if specify_root=$(find_specify_root); then
echo "$specify_root"
return
fi
# Fallback to git if no .specify found
if git rev-parse --show-toplevel >/dev/null 2>&1; then
git rev-parse --show-toplevel
return
fi
# Final fallback to script location for non-git repos
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
(cd "$script_dir/../../.." && pwd)
}
# Get current branch, with fallback for non-git repositories
get_current_branch() {
# First check if SPECIFY_FEATURE environment variable is set
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
echo "$SPECIFY_FEATURE"
return
fi
# Then check git if available at the spec-kit root (not parent)
local repo_root=$(get_repo_root)
if has_git; then
git -C "$repo_root" rev-parse --abbrev-ref HEAD
return
fi
# For non-git repos, try to find the latest feature directory
local specs_dir="$repo_root/specs"
if [[ -d "$specs_dir" ]]; then
local latest_feature=""
local highest=0
local latest_timestamp=""
for dir in "$specs_dir"/*; do
if [[ -d "$dir" ]]; then
local dirname=$(basename "$dir")
if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
# Timestamp-based branch: compare lexicographically
local ts="${BASH_REMATCH[1]}"
if [[ "$ts" > "$latest_timestamp" ]]; then
latest_timestamp="$ts"
latest_feature=$dirname
fi
elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then
local number=${BASH_REMATCH[1]}
number=$((10#$number))
if [[ "$number" -gt "$highest" ]]; then
highest=$number
# Only update if no timestamp branch found yet
if [[ -z "$latest_timestamp" ]]; then
latest_feature=$dirname
fi
fi
fi
fi
done
if [[ -n "$latest_feature" ]]; then
echo "$latest_feature"
return
fi
fi
echo "main" # Final fallback
}
# Check if we have git available at the spec-kit root level
# Returns true only if git is installed and the repo root is inside a git work tree
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
has_git() {
# First check if git command is available (before calling get_repo_root which may use git)
command -v git >/dev/null 2>&1 || return 1
local repo_root=$(get_repo_root)
# Check if .git exists (directory or file for worktrees/submodules)
[ -e "$repo_root/.git" ] || return 1
# Verify it's actually a valid git work tree
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
}
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
spec_kit_effective_branch_name() {
local raw="$1"
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
printf '%s\n' "${BASH_REMATCH[2]}"
else
printf '%s\n' "$raw"
fi
}
check_feature_branch() {
local raw="$1"
local has_git_repo="$2"
# For non-git repos, we can't enforce branch naming but still provide output
if [[ "$has_git_repo" != "true" ]]; then
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
return 0
fi
local branch
branch=$(spec_kit_effective_branch_name "$raw")
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
local is_sequential=false
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
is_sequential=true
fi
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
return 1
fi
return 0
}
# Safely read .specify/feature.json's "feature_directory" value.
# Prints the raw value (possibly relative) to stdout, or empty string if the file
# is missing, unparseable, or does not contain the key. Always returns 0 so callers
# under `set -e` cannot be aborted by parser failure.
# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed.
read_feature_json_feature_directory() {
local repo_root="$1"
local fj="$repo_root/.specify/feature.json"
[[ -f "$fj" ]] || { printf '%s' ''; return 0; }
local _fd=''
if command -v jq >/dev/null 2>&1; then
if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
_fd=''
fi
elif command -v python3 >/dev/null 2>&1; then
# Use Python so pretty-printed/multi-line JSON still parses correctly.
if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then
_fd=''
fi
else
# Last-resort single-line grep/sed fallback. The `|| true` guards against
# grep returning 1 (no match) aborting under `set -e` / `pipefail`.
_fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \
| head -n 1 \
| sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' )
fi
printf '%s' "$_fd"
return 0
}
# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory
# and matches the resolved active FEATURE_DIR (so /speckit-plan can skip git branch pattern checks).
# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`.
feature_json_matches_feature_dir() {
local repo_root="$1"
local active_feature_dir="$2"
local _fd
_fd=$(read_feature_json_feature_directory "$repo_root")
[[ -n "$_fd" ]] || return 1
[[ "$_fd" != /* ]] && _fd="$repo_root/$_fd"
[[ -d "$_fd" ]] || return 1
local norm_json norm_active
norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1
norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1
[[ "$norm_json" == "$norm_active" ]]
}
# Find feature directory by numeric prefix instead of exact branch match
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
find_feature_dir_by_prefix() {
local repo_root="$1"
local branch_name
branch_name=$(spec_kit_effective_branch_name "$2")
local specs_dir="$repo_root/specs"
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
local prefix=""
if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
prefix="${BASH_REMATCH[1]}"
elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then
prefix="${BASH_REMATCH[1]}"
else
# If branch doesn't have a recognized prefix, fall back to exact match
echo "$specs_dir/$branch_name"
return
fi
# Search for directories in specs/ that start with this prefix
local matches=()
if [[ -d "$specs_dir" ]]; then
for dir in "$specs_dir"/"$prefix"-*; do
if [[ -d "$dir" ]]; then
matches+=("$(basename "$dir")")
fi
done
fi
# Handle results
if [[ ${#matches[@]} -eq 0 ]]; then
# No match found - return the branch name path (will fail later with clear error)
echo "$specs_dir/$branch_name"
elif [[ ${#matches[@]} -eq 1 ]]; then
# Exactly one match - perfect!
echo "$specs_dir/${matches[0]}"
else
# Multiple matches - this shouldn't happen with proper naming convention
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
echo "Please ensure only one spec directory exists per prefix." >&2
return 1
fi
}
get_feature_paths() {
local repo_root=$(get_repo_root)
local current_branch=$(get_current_branch)
local has_git_repo="false"
if has_git; then
has_git_repo="true"
fi
# Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit-specify)
# 3. Branch-name-based prefix lookup (legacy fallback)
local feature_dir
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
# Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
# Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on
# missing/unparseable/unset so we fall through to the branch-prefix lookup.
local _fd
_fd=$(read_feature_json_feature_directory "$repo_root")
if [[ -n "$_fd" ]]; then
feature_dir="$_fd"
# Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
echo "ERROR: Failed to resolve feature directory" >&2
return 1
fi
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
echo "ERROR: Failed to resolve feature directory" >&2
return 1
fi
# Use printf '%q' to safely quote values, preventing shell injection
# via crafted branch names or paths containing special characters
printf 'REPO_ROOT=%q\n' "$repo_root"
printf 'CURRENT_BRANCH=%q\n' "$current_branch"
printf 'HAS_GIT=%q\n' "$has_git_repo"
printf 'FEATURE_DIR=%q\n' "$feature_dir"
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"
printf 'TASKS=%q\n' "$feature_dir/tasks.md"
printf 'RESEARCH=%q\n' "$feature_dir/research.md"
printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md"
printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md"
printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts"
}
# Check if jq is available for safe JSON construction
has_jq() {
command -v jq >/dev/null 2>&1
}
get_invoke_separator() {
local repo_root="${1:-$(get_repo_root)}"
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
return 0
fi
local integration_json="$repo_root/.specify/integration.json"
local separator="."
local parsed_with_jq=0
if [[ -f "$integration_json" ]]; then
if command -v jq >/dev/null 2>&1; then
local jq_separator
if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then
parsed_with_jq=1
case "$jq_separator" in
"."|"-") separator="$jq_separator" ;;
esac
fi
fi
if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as fh:
state = json.load(fh)
key = state.get("default_integration") or state.get("integration") or ""
settings = state.get("integration_settings")
separator = "."
if isinstance(key, str) and isinstance(settings, dict):
entry = settings.get(key)
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
separator = entry["invoke_separator"]
print(separator)
except Exception:
print(".")
PY
); then
case "$separator" in
"."|"-") ;;
*) separator="." ;;
esac
else
separator="."
fi
fi
fi
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
printf '%s\n' "$separator"
}
format_speckit_command() {
local command_name="$1"
local repo_root="${2:-$(get_repo_root)}"
local separator
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
else
separator=$(get_invoke_separator "$repo_root")
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
fi
command_name="${command_name#/}"
command_name="${command_name#speckit.}"
command_name="${command_name#speckit-}"
command_name="${command_name//./$separator}"
printf '/speckit%s%s\n' "$separator" "$command_name"
}
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\t'/\\t}"
s="${s//$'\r'/\\r}"
s="${s//$'\b'/\\b}"
s="${s//$'\f'/\\f}"
# Escape any remaining U+0001-U+001F control characters as \uXXXX.
# (U+0000/NUL cannot appear in bash strings and is excluded.)
# LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes,
# so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact.
local LC_ALL=C
local i char code
for (( i=0; i<${#s}; i++ )); do
char="${s:$i:1}"
printf -v code '%d' "'$char" 2>/dev/null || code=256
if (( code >= 1 && code <= 31 )); then
printf '\\u%04x' "$code"
else
printf '%s' "$char"
fi
done
}
check_file() { [[ -f "$1" ]] && echo "$2" || echo "$2"; }
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo "$2" || echo "$2"; }
# Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
# 3. .specify/extensions/<ext-id>/templates/
# 4. .specify/templates/ (core)
resolve_template() {
local template_name="$1"
local repo_root="$2"
local base="$repo_root/.specify/templates"
# Priority 1: Project overrides
local override="$base/overrides/${template_name}.md"
[ -f "$override" ] && echo "$override" && return 0
# Priority 2: Installed presets (sorted by priority from .registry)
local presets_dir="$repo_root/.specify/presets"
if [ -d "$presets_dir" ]; then
local registry_file="$presets_dir/.registry"
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
# Read preset IDs sorted by priority (lower number = higher precedence).
# The python3 call is wrapped in an if-condition so that set -e does not
# abort the function when python3 exits non-zero (e.g. invalid JSON).
local sorted_presets=""
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
import json, sys, os
try:
with open(os.environ['SPECKIT_REGISTRY']) as f:
data = json.load(f)
presets = data.get('presets', {})
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
print(pid)
except Exception:
sys.exit(1)
" 2>/dev/null); then
if [ -n "$sorted_presets" ]; then
# python3 succeeded and returned preset IDs — search in priority order
while IFS= read -r preset_id; do
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done <<< "$sorted_presets"
fi
# python3 succeeded but registry has no presets — nothing to search
else
# python3 failed (missing, or registry parse error) — fall back to unordered directory scan
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
fi
else
# Fallback: alphabetical directory order (no python3 available)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
fi
fi
# Priority 3: Extension-provided templates
local ext_dir="$repo_root/.specify/extensions"
if [ -d "$ext_dir" ]; then
for ext in "$ext_dir"/*/; do
[ -d "$ext" ] || continue
# Skip hidden directories (e.g. .backup, .cache)
case "$(basename "$ext")" in .*) continue;; esac
local candidate="$ext/templates/${template_name}.md"
[ -f "$candidate" ] && echo "$candidate" && return 0
done
fi
# Priority 4: Core templates
local core="$base/${template_name}.md"
[ -f "$core" ] && echo "$core" && return 0
# Template not found in any location.
# Return 1 so callers can distinguish "not found" from "found".
# Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true
return 1
}
# Resolve a template name to composed content using composition strategies.
# Reads strategy metadata from preset manifests and composes content
# from multiple layers using prepend, append, or wrap strategies.
#
# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
# Returns composed content string on stdout; exit code 1 if not found.
resolve_template_content() {
local template_name="$1"
local repo_root="$2"
local base="$repo_root/.specify/templates"
# Collect all layers (highest priority first)
local -a layer_paths=()
local -a layer_strategies=()
# Priority 1: Project overrides (always "replace")
local override="$base/overrides/${template_name}.md"
if [ -f "$override" ]; then
layer_paths+=("$override")
layer_strategies+=("replace")
fi
# Priority 2: Installed presets (sorted by priority from .registry)
local presets_dir="$repo_root/.specify/presets"
if [ -d "$presets_dir" ]; then
local registry_file="$presets_dir/.registry"
local sorted_presets=""
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
import json, sys, os
try:
with open(os.environ['SPECKIT_REGISTRY']) as f:
data = json.load(f)
presets = data.get('presets', {})
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
print(pid)
except Exception:
sys.exit(1)
" 2>/dev/null); then
if [ -n "$sorted_presets" ]; then
local yaml_warned=false
while IFS= read -r preset_id; do
# Read strategy and file path from preset manifest
local strategy="replace"
local manifest_file=""
local manifest="$presets_dir/$preset_id/preset.yml"
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
# Requires PyYAML; falls back to replace/convention if unavailable
local result
local py_stderr
py_stderr=$(mktemp)
result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
import sys, os
try:
import yaml
except ImportError:
print('yaml_missing', file=sys.stderr)
print('replace\t')
sys.exit(0)
try:
with open(os.environ['SPECKIT_MANIFEST']) as f:
data = yaml.safe_load(f)
for t in data.get('provides', {}).get('templates', []):
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
sys.exit(0)
print('replace\t')
except Exception:
print('replace\t')
" 2>"$py_stderr")
local parse_status=$?
if [ $parse_status -eq 0 ] && [ -n "$result" ]; then
IFS=$'\t' read -r strategy manifest_file <<< "$result"
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
fi
if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then
echo "Warning: PyYAML not available; composition strategies may be ignored" >&2
yaml_warned=true
fi
rm -f "$py_stderr"
fi
# Try manifest file path first, then convention path
local candidate=""
if [ -n "$manifest_file" ]; then
# Reject absolute paths and parent traversal
case "$manifest_file" in
/*|*../*|../*) manifest_file="" ;;
esac
fi
if [ -n "$manifest_file" ]; then
local mf="$presets_dir/$preset_id/$manifest_file"
[ -f "$mf" ] && candidate="$mf"
fi
if [ -z "$candidate" ]; then
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
[ -f "$cf" ] && candidate="$cf"
fi
if [ -n "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("$strategy")
fi
done <<< "$sorted_presets"
fi
else
# python3 failed — fall back to unordered directory scan (replace only)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
fi
done
fi
else
# No python3 or registry — fall back to unordered directory scan (replace only)
for preset in "$presets_dir"/*/; do
[ -d "$preset" ] || continue
local candidate="$preset/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
fi
done
fi
fi
# Priority 3: Extension-provided templates (always "replace")
local ext_dir="$repo_root/.specify/extensions"
if [ -d "$ext_dir" ]; then
for ext in "$ext_dir"/*/; do
[ -d "$ext" ] || continue
case "$(basename "$ext")" in .*) continue;; esac
local candidate="$ext/templates/${template_name}.md"
if [ -f "$candidate" ]; then
layer_paths+=("$candidate")
layer_strategies+=("replace")
fi
done
fi
# Priority 4: Core templates (always "replace")
local core="$base/${template_name}.md"
if [ -f "$core" ]; then
layer_paths+=("$core")
layer_strategies+=("replace")
fi
local count=${#layer_paths[@]}
[ "$count" -eq 0 ] && return 1
# Check if any layer uses a non-replace strategy
local has_composition=false
for s in "${layer_strategies[@]}"; do
[ "$s" != "replace" ] && has_composition=true && break
done
# If the top (highest-priority) layer is replace, it wins entirely —
# lower layers are irrelevant regardless of their strategies.
if [ "${layer_strategies[0]}" = "replace" ]; then
cat "${layer_paths[0]}"
return 0
fi
if [ "$has_composition" = false ]; then
cat "${layer_paths[0]}"
return 0
fi
# Find the effective base: scan from highest priority (index 0) downward
# to find the nearest replace layer. Only compose layers above that base.
local base_idx=-1
local i
for (( i=0; i<count; i++ )); do
if [ "${layer_strategies[$i]}" = "replace" ]; then
base_idx=$i
break
fi
done
if [ $base_idx -lt 0 ]; then
return 1 # no base layer found
fi
# Read the base content; compose layers above the base (higher priority)
local content
content=$(cat "${layer_paths[$base_idx]}"; printf x)
content="${content%x}"
for (( i=base_idx-1; i>=0; i-- )); do
local path="${layer_paths[$i]}"
local strat="${layer_strategies[$i]}"
local layer_content
# Preserve trailing newlines
layer_content=$(cat "$path"; printf x)
layer_content="${layer_content%x}"
case "$strat" in
replace) content="$layer_content" ;;
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
wrap)
case "$layer_content" in
*'{CORE_TEMPLATE}'*) ;;
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
esac
while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
local after="${layer_content#*\{CORE_TEMPLATE\}}"
layer_content="${before}${content}${after}"
done
content="$layer_content"
;;
*) echo "Error: unknown strategy '$strat'" >&2; return 1 ;;
esac
done
printf '%s' "$content"
return 0
}

View file

@ -0,0 +1,413 @@
#!/usr/bin/env bash
set -e
JSON_MODE=false
DRY_RUN=false
ALLOW_EXISTING=false
SHORT_NAME=""
BRANCH_NUMBER=""
USE_TIMESTAMP=false
ARGS=()
i=1
while [ $i -le $# ]; do
arg="${!i}"
case "$arg" in
--json)
JSON_MODE=true
;;
--dry-run)
DRY_RUN=true
;;
--allow-existing-branch)
ALLOW_EXISTING=true
;;
--short-name)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --short-name requires a value' >&2
exit 1
fi
i=$((i + 1))
next_arg="${!i}"
# Check if the next argument is another option (starts with --)
if [[ "$next_arg" == --* ]]; then
echo 'Error: --short-name requires a value' >&2
exit 1
fi
SHORT_NAME="$next_arg"
;;
--number)
if [ $((i + 1)) -gt $# ]; then
echo 'Error: --number requires a value' >&2
exit 1
fi
i=$((i + 1))
next_arg="${!i}"
if [[ "$next_arg" == --* ]]; then
echo 'Error: --number requires a value' >&2
exit 1
fi
BRANCH_NUMBER="$next_arg"
;;
--timestamp)
USE_TIMESTAMP=true
;;
--help|-h)
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
echo ""
echo "Options:"
echo " --json Output in JSON format"
echo " --dry-run Compute branch name and paths without creating branches, directories, or files"
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $0 'Add user authentication system' --short-name 'user-auth'"
echo " $0 'Implement OAuth2 integration for API' --number 5"
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
exit 0
;;
*)
ARGS+=("$arg")
;;
esac
i=$((i + 1))
done
FEATURE_DESCRIPTION="${ARGS[*]}"
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
exit 1
fi
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
if [ -z "$FEATURE_DESCRIPTION" ]; then
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
exit 1
fi
# Function to get highest number from specs directory
get_highest_from_specs() {
local specs_dir="$1"
local highest=0
if [ -d "$specs_dir" ]; then
for dir in "$specs_dir"/*; do
[ -d "$dir" ] || continue
dirname=$(basename "$dir")
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done
fi
echo "$highest"
}
# Function to get highest number from git branches
get_highest_from_branches() {
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
}
# Extract the highest sequential feature number from a list of ref names (one per line).
# Shared by get_highest_from_branches and get_highest_from_remote_refs.
_extract_highest_number() {
local highest=0
while IFS= read -r name; do
[ -z "$name" ] && continue
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
number=$((10#$number))
if [ "$number" -gt "$highest" ]; then
highest=$number
fi
fi
done
echo "$highest"
}
# Function to get highest number from remote branches without fetching (side-effect-free)
get_highest_from_remote_refs() {
local highest=0
for remote in $(git remote 2>/dev/null); do
local remote_highest
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
if [ "$remote_highest" -gt "$highest" ]; then
highest=$remote_highest
fi
done
echo "$highest"
}
# Function to check existing branches (local and remote) and return next available number.
# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching.
check_existing_branches() {
local specs_dir="$1"
local skip_fetch="${2:-false}"
if [ "$skip_fetch" = true ]; then
# Side-effect-free: query remotes via ls-remote
local highest_remote=$(get_highest_from_remote_refs)
local highest_branch=$(get_highest_from_branches)
if [ "$highest_remote" -gt "$highest_branch" ]; then
highest_branch=$highest_remote
fi
else
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
git fetch --all --prune >/dev/null 2>&1 || true
local highest_branch=$(get_highest_from_branches)
fi
# Get highest number from ALL specs (not just matching short name)
local highest_spec=$(get_highest_from_specs "$specs_dir")
# Take the maximum of both
local max_num=$highest_branch
if [ "$highest_spec" -gt "$max_num" ]; then
max_num=$highest_spec
fi
# Return next number
echo $((max_num + 1))
}
# Function to clean and format a branch name
clean_branch_name() {
local name="$1"
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}
# Resolve repository root using common.sh functions which prioritize .specify over git
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
REPO_ROOT=$(get_repo_root)
# Check if git is available at this repo root (not a parent)
if has_git; then
HAS_GIT=true
else
HAS_GIT=false
fi
cd "$REPO_ROOT"
SPECS_DIR="$REPO_ROOT/specs"
if [ "$DRY_RUN" != true ]; then
mkdir -p "$SPECS_DIR"
fi
# Function to generate branch name with stop word filtering and length filtering
generate_branch_name() {
local description="$1"
# Common stop words to filter out
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
# Convert to lowercase and split into words
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
local meaningful_words=()
for word in $clean_name; do
# Skip empty words
[ -z "$word" ] && continue
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
if ! echo "$word" | grep -qiE "$stop_words"; then
if [ ${#word} -ge 3 ]; then
meaningful_words+=("$word")
elif echo "$description" | grep -q "\b${word^^}\b"; then
# Keep short words if they appear as uppercase in original (likely acronyms)
meaningful_words+=("$word")
fi
fi
done
# If we have meaningful words, use first 3-4 of them
if [ ${#meaningful_words[@]} -gt 0 ]; then
local max_words=3
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
local result=""
local count=0
for word in "${meaningful_words[@]}"; do
if [ $count -ge $max_words ]; then break; fi
if [ -n "$result" ]; then result="$result-"; fi
result="$result$word"
count=$((count + 1))
done
echo "$result"
else
# Fallback to original logic if no meaningful words found
local cleaned=$(clean_branch_name "$description")
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
fi
}
# Generate branch name
if [ -n "$SHORT_NAME" ]; then
# Use provided short name, just clean it up
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
else
# Generate from description with smart filtering
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
fi
# Warn if --number and --timestamp are both specified
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
BRANCH_NUMBER=""
fi
# Determine branch prefix
if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else
# Determine branch number
if [ -z "$BRANCH_NUMBER" ]; then
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
elif [ "$DRY_RUN" = true ]; then
# Dry-run without git: local spec dirs only
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
elif [ "$HAS_GIT" = true ]; then
# Check existing branches on remotes
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
else
# Fall back to local directory check
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$((HIGHEST + 1))
fi
fi
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
fi
# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
MAX_BRANCH_LENGTH=244
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
# Calculate how much we need to trim from suffix
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
# Truncate suffix at word boundary if possible
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
# Remove trailing hyphen if truncation created one
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
fi
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
SPEC_FILE="$FEATURE_DIR/spec.md"
if [ "$DRY_RUN" != true ]; then
if [ "$HAS_GIT" = true ]; then
branch_create_error=""
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
# Check if branch already exists
if git branch --list "$BRANCH_NAME" | grep -q .; then
if [ "$ALLOW_EXISTING" = true ]; then
# If we're already on the branch, continue without another checkout.
if [ "$current_branch" = "$BRANCH_NAME" ]; then
:
# Otherwise switch to the existing branch instead of failing.
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
if [ -n "$switch_branch_error" ]; then
>&2 printf '%s\n' "$switch_branch_error"
fi
exit 1
fi
elif [ "$USE_TIMESTAMP" = true ]; then
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
exit 1
else
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
exit 1
fi
else
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
if [ -n "$branch_create_error" ]; then
>&2 printf '%s\n' "$branch_create_error"
else
>&2 echo "Please check your git configuration and try again."
fi
exit 1
fi
fi
else
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
fi
mkdir -p "$FEATURE_DIR"
if [ ! -f "$SPEC_FILE" ]; then
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
cp "$TEMPLATE" "$SPEC_FILE"
else
echo "Warning: Spec template not found; created empty spec file" >&2
touch "$SPEC_FILE"
fi
fi
# Inform the user how to persist the feature variable in their own shell
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
fi
if $JSON_MODE; then
if command -v jq >/dev/null 2>&1; then
if [ "$DRY_RUN" = true ]; then
jq -cn \
--arg branch_name "$BRANCH_NAME" \
--arg spec_file "$SPEC_FILE" \
--arg feature_num "$FEATURE_NUM" \
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}'
else
jq -cn \
--arg branch_name "$BRANCH_NAME" \
--arg spec_file "$SPEC_FILE" \
--arg feature_num "$FEATURE_NUM" \
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
fi
else
if [ "$DRY_RUN" = true ]; then
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
else
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
fi
fi
else
echo "BRANCH_NAME: $BRANCH_NAME"
echo "SPEC_FILE: $SPEC_FILE"
echo "FEATURE_NUM: $FEATURE_NUM"
if [ "$DRY_RUN" != true ]; then
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
fi
fi

View file

@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -e
# Parse command line arguments
JSON_MODE=false
ARGS=()
for arg in "$@"; do
case "$arg" in
--json)
JSON_MODE=true
;;
--help|-h)
echo "Usage: $0 [--json]"
echo " --json Output results in JSON format"
echo " --help Show this help message"
exit 0
;;
*)
ARGS+=("$arg")
;;
esac
done
# Get script directory and load common functions
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
# Get all paths and variables from common functions
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
eval "$_paths_output"
unset _paths_output
# If feature.json pins an existing feature directory, branch naming is not required.
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
fi
# Ensure the feature directory exists
mkdir -p "$FEATURE_DIR"
# Copy plan template if plan doesn't already exist
if [[ -f "$IMPL_PLAN" ]]; then
if $JSON_MODE; then
echo "Plan already exists at $IMPL_PLAN, skipping template copy" >&2
else
echo "Plan already exists at $IMPL_PLAN, skipping template copy"
fi
else
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
cp "$TEMPLATE" "$IMPL_PLAN"
if $JSON_MODE; then
echo "Copied plan template to $IMPL_PLAN" >&2
else
echo "Copied plan template to $IMPL_PLAN"
fi
else
if $JSON_MODE; then
echo "Warning: Plan template not found" >&2
else
echo "Warning: Plan template not found"
fi
# Create a basic plan file if template doesn't exist
touch "$IMPL_PLAN"
fi
fi
# Output results
if $JSON_MODE; then
if has_jq; then
jq -cn \
--arg feature_spec "$FEATURE_SPEC" \
--arg impl_plan "$IMPL_PLAN" \
--arg specs_dir "$FEATURE_DIR" \
--arg branch "$CURRENT_BRANCH" \
--arg has_git "$HAS_GIT" \
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}'
else
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")"
fi
else
echo "FEATURE_SPEC: $FEATURE_SPEC"
echo "IMPL_PLAN: $IMPL_PLAN"
echo "SPECS_DIR: $FEATURE_DIR"
echo "BRANCH: $CURRENT_BRANCH"
echo "HAS_GIT: $HAS_GIT"
fi

View file

@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -e
# Parse command line arguments
JSON_MODE=false
for arg in "$@"; do
case "$arg" in
--json) JSON_MODE=true ;;
--help|-h)
echo "Usage: $0 [--json]"
echo " --json Output results in JSON format"
echo " --help Show this help message"
exit 0
;;
*) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;;
esac
done
# Source common functions
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
# Get feature paths
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
eval "$_paths_output"
unset _paths_output
# Validate branch
# If feature.json pins an existing feature directory, branch naming is not required.
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
fi
if [[ ! -f "$IMPL_PLAN" ]]; then
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
echo "Run /speckit-plan first to create the implementation plan." >&2
exit 1
fi
if [[ ! -f "$FEATURE_SPEC" ]]; then
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
echo "Run /speckit-specify first to create the feature structure." >&2
exit 1
fi
# Build available docs list
docs=()
[[ -f "$RESEARCH" ]] && docs+=("research.md")
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
docs+=("contracts/")
fi
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
# Resolve tasks template through override stack
TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true
if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then
echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2
echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2
exit 1
fi
# Output results
if $JSON_MODE; then
if has_jq; then
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
else
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
fi
jq -cn \
--arg feature_dir "$FEATURE_DIR" \
--argjson docs "$json_docs" \
--arg tasks_template "${TASKS_TEMPLATE:-}" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}'
else
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
else
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
json_docs="[${json_docs%,}]"
fi
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")"
fi
else
echo "FEATURE_DIR: $FEATURE_DIR"
echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}"
echo "AVAILABLE_DOCS:"
check_file "$RESEARCH" "research.md"
check_file "$DATA_MODEL" "data-model.md"
check_dir "$CONTRACTS_DIR" "contracts/"
check_file "$QUICKSTART" "quickstart.md"
fi

View file

@ -0,0 +1,40 @@
# [CHECKLIST TYPE] Checklist: [FEATURE NAME]
**Purpose**: [Brief description of what this checklist covers]
**Created**: [DATE]
**Feature**: [Link to spec.md or relevant documentation]
**Note**: This checklist is generated by the `/speckit-checklist` command based on feature context and requirements.
<!--
============================================================================
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
The /speckit-checklist command MUST replace these with actual items based on:
- User's specific checklist request
- Feature requirements from spec.md
- Technical context from plan.md
- Implementation details from tasks.md
DO NOT keep these sample items in the generated checklist file.
============================================================================
-->
## [Category 1]
- [ ] CHK001 First checklist item with clear action
- [ ] CHK002 Second checklist item
- [ ] CHK003 Third checklist item
## [Category 2]
- [ ] CHK004 Another category item
- [ ] CHK005 Item with specific criteria
- [ ] CHK006 Final item in this category
## Notes
- Check items off as completed: `[x]`
- Add comments or findings inline
- Link to relevant resources or documentation
- Items are numbered sequentially for easy reference

View file

@ -0,0 +1,50 @@
# [PROJECT_NAME] Constitution
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
## Core Principles
### [PRINCIPLE_1_NAME]
<!-- Example: I. Library-First -->
[PRINCIPLE_1_DESCRIPTION]
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
### [PRINCIPLE_2_NAME]
<!-- Example: II. CLI Interface -->
[PRINCIPLE_2_DESCRIPTION]
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
### [PRINCIPLE_3_NAME]
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
[PRINCIPLE_3_DESCRIPTION]
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
### [PRINCIPLE_4_NAME]
<!-- Example: IV. Integration Testing -->
[PRINCIPLE_4_DESCRIPTION]
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
### [PRINCIPLE_5_NAME]
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
[PRINCIPLE_5_DESCRIPTION]
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
## [SECTION_2_NAME]
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
[SECTION_2_CONTENT]
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
## [SECTION_3_NAME]
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
[SECTION_3_CONTENT]
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
## Governance
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
[GOVERNANCE_RULES]
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->

View file

@ -0,0 +1,113 @@
# Implementation Plan: [FEATURE]
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
**Note**: This template is filled in by the `/speckit-plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
## Summary
[Extract from feature spec: primary requirement + technical approach from research]
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
[Gates determined based on constitution file]
## Project Structure
### Documentation (this feature)
```text
specs/[###-feature]/
├── plan.md # This file (/speckit-plan command output)
├── research.md # Phase 0 output (/speckit-plan command)
├── data-model.md # Phase 1 output (/speckit-plan command)
├── quickstart.md # Phase 1 output (/speckit-plan command)
├── contracts/ # Phase 1 output (/speckit-plan command)
└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT)
src/
├── models/
├── services/
├── cli/
└── lib/
tests/
├── contract/
├── integration/
└── unit/
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected)
backend/
├── src/
│ ├── models/
│ ├── services/
│ └── api/
└── tests/
frontend/
├── src/
│ ├── components/
│ ├── pages/
│ └── services/
└── tests/
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
api/
└── [same as backend above]
ios/ or android/
└── [platform-specific structure: feature modules, UI flows, platform tests]
```
**Structure Decision**: [Document the selected structure and reference the real
directories captured above]
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |

View file

@ -0,0 +1,131 @@
# Feature Specification: [FEATURE NAME]
**Feature Branch**: `[###-feature-name]`
**Created**: [DATE]
**Status**: Draft
**Input**: User description: "$ARGUMENTS"
## User Scenarios & Testing *(mandatory)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - [Brief Title] (Priority: P1)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
2. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
### User Story 2 - [Brief Title] (Priority: P2)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
### User Story 3 - [Brief Title] (Priority: P3)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
[Add more user stories as needed, each with an assigned priority]
### Edge Cases
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right edge cases.
-->
- What happens when [boundary condition]?
- How does system handle [error scenario]?
## Requirements *(mandatory)*
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.
-->
### Functional Requirements
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
*Example of marking unclear requirements:*
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
### Key Entities *(include if feature involves data)*
- **[Entity 1]**: [What it represents, key attributes without implementation]
- **[Entity 2]**: [What it represents, relationships to other entities]
## Success Criteria *(mandatory)*
<!--
ACTION REQUIRED: Define measurable success criteria.
These must be technology-agnostic and measurable.
-->
### Measurable Outcomes
- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"]
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
## Assumptions
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right assumptions based on reasonable defaults
chosen when the feature description did not specify certain details.
-->
- [Assumption about target users, e.g., "Users have stable internet connectivity"]
- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"]
- [Assumption about data/environment, e.g., "Existing authentication system will be reused"]
- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"]

View file

@ -0,0 +1,252 @@
---
description: "Task list template for feature implementation"
---
# Tasks: [FEATURE NAME]
**Input**: Design documents from `/specs/[###-feature-name]/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Path Conventions
- **Single project**: `src/`, `tests/` at repository root
- **Web app**: `backend/src/`, `frontend/src/`
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
- Paths shown below assume single project - adjust based on plan.md structure
<!--
============================================================================
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
The /speckit-tasks command MUST replace these with actual tasks based on:
- User stories from spec.md (with their priorities P1, P2, P3...)
- Feature requirements from plan.md
- Entities from data-model.md
- Endpoints from contracts/
Tasks MUST be organized by user story so each story can be:
- Implemented independently
- Tested independently
- Delivered as an MVP increment
DO NOT keep these sample tasks in the generated tasks.md file.
============================================================================
-->
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Project initialization and basic structure
- [ ] T001 Create project structure per implementation plan
- [ ] T002 Initialize [language] project with [framework] dependencies
- [ ] T003 [P] Configure linting and formatting tools
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
Examples of foundational tasks (adjust based on your project):
- [ ] T004 Setup database schema and migrations framework
- [ ] T005 [P] Implement authentication/authorization framework
- [ ] T006 [P] Setup API routing and middleware structure
- [ ] T007 Create base models/entities that all stories depend on
- [ ] T008 Configure error handling and logging infrastructure
- [ ] T009 Setup environment configuration management
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
---
## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py
### Implementation for User Story 1
- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py
- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T016 [US1] Add validation and error handling
- [ ] T017 [US1] Add logging for user story 1 operations
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
---
## Phase 4: User Story 2 - [Title] (Priority: P2)
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py
### Implementation for User Story 2
- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py
- [ ] T021 [US2] Implement [Service] in src/services/[service].py
- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T023 [US2] Integrate with User Story 1 components (if needed)
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
---
## Phase 5: User Story 3 - [Title] (Priority: P3)
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py
### Implementation for User Story 3
- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py
- [ ] T027 [US3] Implement [Service] in src/services/[service].py
- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py
**Checkpoint**: All user stories should now be independently functional
---
[Add more user story phases as needed, following the same pattern]
---
## Phase N: Polish & Cross-Cutting Concerns
**Purpose**: Improvements that affect multiple user stories
- [ ] TXXX [P] Documentation updates in docs/
- [ ] TXXX Code cleanup and refactoring
- [ ] TXXX Performance optimization across all stories
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
- [ ] TXXX Security hardening
- [ ] TXXX Run quickstart.md validation
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies - can start immediately
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
- User stories can then proceed in parallel (if staffed)
- Or sequentially in priority order (P1 → P2 → P3)
- **Polish (Final Phase)**: Depends on all desired user stories being complete
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable
### Within Each User Story
- Tests (if included) MUST be written and FAIL before implementation
- Models before services
- Services before endpoints
- Core implementation before integration
- Story complete before moving to next priority
### Parallel Opportunities
- All Setup tasks marked [P] can run in parallel
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)
- All tests for a user story marked [P] can run in parallel
- Models within a story marked [P] can run in parallel
- Different user stories can be worked on in parallel by different team members
---
## Parallel Example: User Story 1
```bash
# Launch all tests for User Story 1 together (if tests requested):
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
Task: "Integration test for [user journey] in tests/integration/test_[name].py"
# Launch all models for User Story 1 together:
Task: "Create [Entity1] model in src/models/[entity1].py"
Task: "Create [Entity2] model in src/models/[entity2].py"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
3. Complete Phase 3: User Story 1
4. **STOP and VALIDATE**: Test User Story 1 independently
5. Deploy/demo if ready
### Incremental Delivery
1. Complete Setup + Foundational → Foundation ready
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
3. Add User Story 2 → Test independently → Deploy/Demo
4. Add User Story 3 → Test independently → Deploy/Demo
5. Each story adds value without breaking previous stories
### Parallel Team Strategy
With multiple developers:
1. Team completes Setup + Foundational together
2. Once Foundational is done:
- Developer A: User Story 1
- Developer B: User Story 2
- Developer C: User Story 3
3. Stories complete and integrate independently
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- Each user story should be independently completable and testable
- Verify tests fail before implementing
- Commit after each task or logical group
- Stop at any checkpoint to validate story independently
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence

View file

@ -0,0 +1,77 @@
schema_version: "1.0"
workflow:
id: "speckit"
name: "Full SDD Cycle"
version: "1.0.0"
author: "GitHub"
description: "Runs specify → plan → tasks → implement with review gates"
requires:
# 0.8.5 is the first release with engine-side resolution of the
# ``integration: "auto"`` default. Older versions would treat "auto"
# as a literal integration key and fail at dispatch.
speckit_version: ">=0.8.5"
integrations:
# The four commands below (specify, plan, tasks, implement) are core
# spec-kit commands provided by every integration. The list here is an
# advisory, non-exhaustive compatibility hint following the documented
# ``any: [...]`` schema -- it is NOT a closed set. The workflow runs
# against any integration the project was initialized with, including
# ones not listed below, as long as that integration provides the four
# core commands referenced in ``steps``.
any:
- "claude"
- "copilot"
- "gemini"
- "opencode"
inputs:
spec:
type: string
required: true
prompt: "Describe what you want to build"
integration:
type: string
default: "auto"
prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
scope:
type: string
default: "full"
enum: ["full", "backend-only", "frontend-only"]
steps:
- id: specify
command: speckit.specify
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: review-spec
type: gate
message: "Review the generated spec before planning."
options: [approve, reject]
on_reject: abort
- id: plan
command: speckit.plan
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: review-plan
type: gate
message: "Review the plan before generating tasks."
options: [approve, reject]
on_reject: abort
- id: tasks
command: speckit.tasks
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: implement
command: speckit.implement
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"

View file

@ -0,0 +1,13 @@
{
"schema_version": "1.0",
"workflows": {
"speckit": {
"name": "Full SDD Cycle",
"version": "1.0.0",
"description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
"source": "bundled",
"installed_at": "2026-06-08T16:25:46.697608+00:00",
"updated_at": "2026-06-08T16:25:46.697650+00:00"
}
}
}

191
BACKLOG.md Normal file
View file

@ -0,0 +1,191 @@
# Backlog — Ligbox Ops Platform (VM122)
**Última atualização:** 2026-06-17 (Specs **014025** + VM123 finance stack)
**Projeto:** `ligbox-ops-platform`
**VM122:** `ligbox-ops` · `10.10.10.122` · SSH WAN `:2522`
**VM112:** Portal/Wizard — integração **API + webhooks** (fora do compose)
**VM123:** Finance stack — FOSSBilling + Odoo 16 + OpenPanel · SSH WAN `:2523`
**Visão:** `docs/architecture/VISAO_PLATAFORMA_LIGBOX_OPS.md`
**Specs:** `specs/` (Spec Kit)
---
## Legenda
| Prioridade | Significado |
|------------|-------------|
| **P0** | Bloqueia MVP / produção |
| **P1** | Sprint actual |
| **P2** | Importante, pós-MVP |
| **P3** | Futuro |
| Estado | Significado |
|--------|-------------|
| 📋 | Backlog |
| 🔄 | Em curso |
| ✅ | Concluído |
| 🔀 | Consolidada noutra spec |
---
## Decisões fechadas
| Data | Tema | Decisão |
|------|------|---------|
| 2026-06-04 | VM alvo | Ops na VM113 (plano inicial) |
| 2026-06-08 | VM alvo | **VM122** criada (8 GB, SQLite MVP) |
| 2026-06-08 | Storage | SQLite no MVP (sem Postgres) |
| 2026-06-08 | VM112 | **Não** entra no compose — só API/webhooks |
| 2026-06-10 | Mail Desk | **VM108** `@ligbox.com.br` via LMTP |
| 2026-06-10 | Spec 007 | Push mobile/web — draft (ntfy + PWA) |
| 2026-06-10 | Spec 010 | Assist/takeover ASM — **P0**, decisões Roger fechadas |
| 2026-06-10 | Spec 011 | OTRS VM112 — stub futuro (pós 010) |
| 2026-06-10 | Ticket onboarding | **1 ticket em `onboarding.started`** no «Criar conta» VM112 |
| 2026-06-10 | Spec 012 | Abandono → Lead CRM — Fase A+B ✅ |
| 2026-06-10 | Spec 013 | Migração e-mail — **migrar antes do DNS** |
| 2026-06-16 | Spec 015 | Módulos Desk — activar/desactivar sem quebrar núcleo |
| 2026-06-16 | Spec 017/018 | Purge VM112 + Orquestração Serviços (MOSP) |
| 2026-06-17 | Spec 023 | Billing Desk Fase 1 — Odoo primário, gateway fase 2 |
| 2026-06-17 | Spec 024 | VM123 FOSS + Odoo + OpenPanel · Opção B domínios ligbox |
| 2026-06-17 | Spec 025 | Onboarding contínuo — Fase 1 idempotência create |
---
## Specs concluídas
| # | Feature | Notas |
|---|---------|-------|
| **001** | `webhook-vm112-integration` | Funil + company gate + tickets |
| **002** | `wazuh-integration` | Ingress genérico + VM104 |
| **003** | `desk-auth-rbac` | Login JWT, root/admin/mini/noc |
| **004** | `desk-account-management` | Cadastro · VM108 · 2-de-3 · TOTP · pt-BR |
| **022** | `carbonio-account-exists-release` | Bloqueios Carbonio + zmprov VM112 |
**API:** `0.9.6-spec019-023`
**URLs:** `desk.ligbox.com.br` · `api.ops.ligbox.com.br` · `financeiro.ligbox.com.br` · `openpanel.ligbox.com.br`
---
## Fila Spec Kit (014025)
| # | Feature | Prioridade | Estado | Pendente principal |
|---|---------|------------|--------|-------------------|
| **007** | `mobile-push-notifications` | P1 | 📋 | Fases AC (ntfy + PWA) |
| **010** | `desk-assist-takeover` | **P0** | 🔄 | Fase D: push 007, auto-escalada |
| **011** | `integration-otrs` | P2 | 📋 | Stub futuro |
| **012** | `abandoned-onboarding-lead` | P1 | 🔄 | Fase C outreach · Fase D CRM |
| **013** | `email-server-migration` | P0 | 📋 | Design completo — execução em 019 |
| **014** | `funnel-phase-timing` | P1 | 🔄 | Validação E2E formal |
| **015** | `desk-module-registry` | P0 | 🔄 | Evolução modular contínua |
| **016** | `onboard-self-service-prefill` | P0 | 🔄 | Regressão UX / testes |
| **017** | `vm112-domain-orchestration` | P1 | 🔄 | Fase 3 VM112 passos tempo real |
| **018** | `service-orchestration` | P1 | 🔄 | Fase 2 API clients · Fase 3 multi-wizard |
| **019** | `email-migration-vm122-execution` | P0 | 🔄 | PST upload · hook VM112 · piloto |
| **020** | `purge-history-desk` | — | 🔀 | Consolidada na **017 v2** |
| **021** | `wizard-cybersecurity-telemetry` | P1 | 🔄 | Deploy middleware VM112 · push ntfy |
| **023** | `billing-recurrence-desk-visibility` | P1 | 🔄 | **Fase 1 ✅** · Fase 2 gateway ASAAS/Iugu |
| **024** | `openpanel-fossbilling` | P1 | ✅ | v1 piloto concluído 17/06 |
| **025** | `wizard-onboarding-continuity` | **P0** | 🔄 | **Fase 1 ✅** · Fase 2 resume + RAM 16GB |
---
## Track A — Auditoria & Ops Dashboard
| ID | P | Item | Estado |
|----|---|------|--------|
| **OPS-1** | P0 | VM Ops (VM122) Debian 12 + fail2ban | ✅ |
| **OPS-2** | P0 | `docker-compose.mvp.yml` | ✅ |
| **OPS-3** | P0 | `tenant-registry` (VM112 = 1º nó) | ✅ |
| **OPS-7** | P1 | VM123 finance stack (Spec 024) | ✅ |
| **AUD-1** | P0 | Collectors: Carbonio, DNS, nginx | 🔄 parcial |
| **AUD-2** | P0 | UI `/ops/overview` + API scorecard | 🔄 parcial |
| **AUD-3** | P1 | Scorecard por domínio (8 checks) | 🔄 |
| **MIG-1** | **P0** | Módulo migração e-mail (Spec 013/019) | 🔄 MVP |
| **MIG-2** | **P0** | Gate DNS — migrar antes de MX | 🔄 gate OK |
| **MIG-3** | P0 | Pipeline PST (readpst + imap-upload) | 📋 |
| **WZ-1** | P1 | Wazuh agent EmailServers + VM123 | 🔄 VM123 ✅ |
| **WZ-2** | P2 | UI Wazuh filtro origem | ✅ |
---
## Track B — Support Desk
| ID | P | Item | Estado |
|----|---|------|--------|
| **DESK-1** | P0 | UI tickets + timeline | ✅ MVP |
| **DESK-2** | P0 | Modelo tickets + estados SQLite | ✅ |
| **INT-2** | P0 | Webhooks VM112 → VM122 | ✅ |
| **DESK-4** | **P0** | Assist/takeover ASM — Spec 010 A+B+C+F | 🔄 |
| **DESK-5** | P1 | Orquestração Serviços MOSP (018) | 🔄 Fase 1 |
| **DESK-6** | P1 | Billing visibilidade 💳 (023 Fase 1) | ✅ |
| **INT-1** | P2 | OTRS API bridge — Spec 011 | 📋 |
| **DESK-3** | P2 | Kanban, SLA (após Spec 010) | 📋 → Spec 008 |
| **AG-1** | P3 | Agentes IA + runbooks | 📋 |
---
## Track RBAC & Auth
| ID | P | Item | Estado |
|----|---|------|--------|
| **OPS-4** | P0 | RBAC: super_admin, ops_lead, technician, noc | ✅ |
| **OPS-6** | P0 | Auth JWT Desk (login UI) | ✅ |
| **OPS-5** | P2 | Roles client_domain_admin (futuro) | 📋 |
### Utilizadores Desk (VM122)
| User | Role | Função |
|------|------|--------|
| `root` | super_admin | Roger — tudo |
| `admin` | ops_lead | Chefe ops |
| `mini` | technician | Suporte N1/N2 |
| `noc` | noc | Monitorização (leitura) |
---
## VM123 — Finance Stack (Spec 024)
| Serviço | URL | Estado |
|---------|-----|--------|
| FOSSBilling Admin | `https://financeiro.ligbox.com.br/admin` | ✅ HTTPS |
| FOSSBilling Cliente | `https://financeiro.ligbox.com.br/login` | ✅ |
| Odoo 16 | `https://financeiro.ligbox.com.br/odoo/web/login?db=ligbox` | ✅ |
| OpenPanel | `https://openpanel.ligbox.com.br` | ✅ |
| OpenAdmin | `https://admin.openpanel.ligbox.com.br` | ✅ |
| Bridge Community API | `http://10.10.10.123:18087` | ✅ |
**Credenciais:** `deploy/vm123-finance-stack/CREDENCIAIS_SERVICOS_VM123.txt`
---
## Prioridades P0/P1 — próximo sprint
1. **025 Fase 2** — resume wizard + VM112 16 GB + Traefik YAML validation
2. **025 Fase 2** — resume wizard + VM112 16 GB + Traefik YAML validation
3. **023 Fase 2** — gateway pagamento (ASAAS vs Iugu)
4. **019** — piloto migração real + hook VM112 gate DNS
5. **018 Fase 2** — API `clients` + `service_instances`
6. **012 Fase C** — outreach abandonos
7. **007** — push ntfy (desbloqueia 010-D e 021)
---
## Portal VM112 (repo separado)
| ID | Item | Estado |
|----|------|--------|
| OPS-1/2 diarissima | DNS + LE + webmail | ✅ |
| WIZ-025 | Onboarding contínuo Fase 1 | ✅ |
| SUP-3.2 | OTRS no `/escalate` | 📋 → Spec **011** |
| SUP-4.1/4.2 | Painel humano ASM + SLA cliente | 📋 → Spec **010** |
| PRD-3 | Painel corporativo UI | 📋 |
---
## Como actualizar
- Spec concluída → actualizar esta tabela + `specs/NNN/tasks.md`
- Sync Obsidian: `rsync -av /opt/ligbox-ops-platform/ /root/obsidian-infra/ligbox-ops-platform/`
- GitHub: `itecnologys/ligbox-ops-platform`
- Deploy VM122: `/opt/ligbox-ops-platform/`

9
Dockerfile Normal file
View file

@ -0,0 +1,9 @@
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends dnsutils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
ENV PYTHONPATH=/app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

View file

@ -0,0 +1,243 @@
# Feature Specification: Migração E-mail Legado — Execução VM122 (019)
**Criado:** 2026-06-16
**Solicitado por:** Roger
**Status:** 📋 Aprovado para planeamento / implementação
**Prioridade:** **P0**
**Depende de:** Spec 013 (modelo completo), Spec 010 (tickets), Spec 018 (Serviços MOSP)
**Wizard cliente:** permanece na **VM112****não** executa migração legada
---
## Resumo executivo
| Onde | O quê |
|------|--------|
| **VM112** | Wizard onboarding — criar domínio/conta Carbonio, DNS **só após gate** |
| **VM122** | **Orquestração OPS** — migrar e-mail do servidor **anterior/legado** → Carbonio VM112 |
**Regra de ouro (Roger):**
**Migrar → validar → aprovar gate → só depois virar DNS (MX).**
O cliente **não** vê imapsync nem PST no wizard. O técnico sénior opera no **Desk VM122** (vista Email Migration + ticket).
---
## Porquê VM122 e não VM112?
| Critério | VM112 (wizard) | VM122 (Desk) |
|----------|----------------|--------------|
| Público | Cliente final | Técnico OPS |
| Duração | minutos | horas / dias |
| Credenciais servidor antigo | ❌ nunca | ✅ vault encriptado |
| Ferramentas pesadas (imapsync, PST) | ❌ | ✅ worker/host |
| Auditoria / ticket | parcial | completa |
| Gate antes DNS | consulta API | controla e aprova |
---
## Ferramentas GitHub (rápidas e seguras)
| Ferramenta | Repositório | Uso | Maturidade |
|------------|-------------|-----|------------|
| **imapsync** | [imapsync/imapsync](https://github.com/imapsync/imapsync) | IMAP → IMAP (cPanel, Zimbra, O365, Gmail…) | ⭐ ~4k — **padrão indústria** |
| **imap-upload** | [rgladwell/imap-upload](https://github.com/rgladwell/imap-upload) | mbox → IMAP (pós readpst) | Complemento PST |
| **readpst** | `pst-utils` (Debian) | Extrair PST Outlook | Sistema |
| **zmmailbox TGZ** | Carbonio nativo | Zimbra/Carbonio → Carbonio | Oficial Zextras |
| **oauth2_imap** | imapsync.lamiral.info | O365 / Gmail moderno | Obrigatório se Basic Auth off |
**Não recomendado MVP:** ferramentas comerciais fechadas, scripts aleatórios sem logs, migração manual sem gate.
### Boas práticas imapsync (oficial)
1. `--justlogin` + `--dry` + `--justfolders` **antes** do sync real
2. Credenciais em **ficheiro 600**, nunca na linha de comando
3. **Presync** (bulk) com MX ainda no servidor antigo
4. **Delta sync** agendado (6/6h)
5. **Sync final** na janela de cutover
6. `--maxbytespersecond` se origem limitar rate
7. O365: **OAuth2**, não password básica
Fontes: [FAQ Migration Plan](https://imapsync.lamiral.info/FAQ.d/FAQ.Migration_Plan.txt), [FAQ Massive](https://github.com/imapsync/imapsync/blob/master/FAQ.d/FAQ.Massive.txt)
---
## Arquitectura VM122
```
┌─────────────────────────────────────────────────────────────┐
│ Desk VM122 (ligbox-ops-platform) │
│ UI: Email Migration │ API /api/v1/migration/* │
│ Worker + ferramentas │ Gate DNS → bloqueia wizard VM112 │
└────────────┬───────────────────────────────┬────────────────┘
│ imapsync / PST pipeline │ GET /migration/gate
▼ ▼
Servidor LEGADO (host1) VM112 Carbonio (host2)
cPanel / Zimbra / O365 mail.{dominio}
```
### Onde correm as ferramentas
| Fase piloto | Host |
|-------------|------|
| **Agora** | VM122 host ou container worker (fora da API) |
| **Produção volume** | VM123 dedicada `ligbox-migration` (Spec 013 infrastructure.md) |
**Nunca** dentro do container API FastAPI (bloqueia event loop, sem ferramentas).
---
## Fluxo operacional (técnico sénior)
```mermaid
sequenceDiagram
participant T as Técnico Desk
participant V122 as VM122 API/Worker
participant LEG as Servidor legado
participant V112 as Carbonio VM112
participant W as Wizard VM112
T->>V122: Criar job migração (domínio, mailboxes)
T->>V122: Preflight (--justlogin)
V122->>LEG: Teste IMAP origem
V122->>V112: Teste IMAP destino
T->>V122: Sync initial (MX ainda no legado)
V122->>LEG: imapsync bulk
V122->>V112: grava mensagens
loop Delta
T->>V122: Sync delta
end
T->>V122: Verify ≥99%
T->>V122: Approve gate (ops_lead)
W->>V122: GET /migration/gate?domain=
V122-->>W: ready_for_dns
T->>W: Cutover DNS (ou assist)
T->>V122: Sync final
T->>V122: Close job + relatório ticket
```
---
## Integração com wizard VM112
| Momento | VM112 | VM122 |
|---------|-------|-------|
| Cliente cria conta | ✅ wizard | job `discovered` manual ou webhook |
| Contas destino Carbonio | ✅ zmprov via wizard | preflight confirma |
| Aplicar MX Cloudflare | ⚠️ **bloqueado** se gate ≠ `ready_for_dns` | gate API |
| Override emergência | — | `super_admin` + motivo auditado |
**Implementação gate (Fase B):**
`GET /api/v1/migration/gate?domain=` — VM112 chama antes de `dns.applied` final.
---
## Fases e critérios (resumo Spec 013)
| Fase | DNS virado? | Acção |
|------|-------------|-------|
| discovered | Não | Inventário mailboxes |
| preflight | Não | Testes login + mapeamento pastas |
| initial_sync | Não | imapsync bulk |
| delta_sync | Não | incrementais |
| cutover_ready | Não | verify ≥99%, aprovação ops_lead |
| dns_cutover | **Sim** | MX → VM112 |
| final_sync | Sim | última delta |
| verified / closed | Sim | relatório ticket |
---
## Matriz de risco (Roger)
| Risco | Nível | Impacto | Mitigação |
|-------|-------|---------|-----------|
| Virar MX antes da migração | 🔴 **Crítico** | Perda de e-mail novo + antigo separados | **Gate API** + procedimento OPS |
| PST corrompido | 🟠 Alto | Gaps silenciosos | readpst + quarentena + verify |
| O365 Basic Auth bloqueado | 🟠 Alto | Sync falha | OAuth2 (`oauth2_imap`) |
| Duplicatas em re-sync | 🟡 Médio | Inbox duplicado | imapsync Message-Id; não misturar PST+imap mesma pasta |
| Rate limit servidor origem | 🟡 Médio | IP banido | `--maxbytespersecond`, horários off-peak |
| Mailbox gigante (50GB+) | 🟡 Médio | Timeout | sync por pasta; worker 24h retomável |
| Credenciais em log | 🔴 Crítico | Compromisso contas | vault Fernet; passfile 600 |
| Carga VM122 | 🟡 Médio | Desk lento | worker separado / VM123 futuro |
| Cliente envia mail durante cutover | 🟡 Médio | Algumas msgs no legado | sync final + TTL MX baixo pré-cutover |
**Nível global da etapa:** 🟠 **ALTO** — dados de produção irreversíveis se mal executado.
**Com Spec 013 + gate + presync:** 🟡 **MÉDIO controlável** para técnico sénior com runbook.
---
## Plano de implementação (como vamos proceder)
### Fase A — Fundação (VM122, ~1 sprint)
1. Schema SQLite (`migration_jobs`, `mailboxes`, `runs`, `credentials`) — Spec 013 data-model
2. `install-migration-tools.sh` na VM122 (imapsync, pst-utils, imap-upload)
3. API CRUD jobs + preflight `--justlogin`
4. Worker `migration_runner.py` — 1 mailbox imapsync
5. UI Desk mínima: lista jobs + log
### Fase B — Gate DNS (~½ sprint)
6. `gate.py` — ratio 99%, estados blocked/warning/ready
7. `GET /migration/gate?domain=` para VM112
8. Integração ticket + notas por `migration_run`
### Fase C — PST + verify (~1 sprint)
9. Upload PST multipart
10. Pipeline readpst → imap-upload
11. Relatório verify + approve-gate
### Fase D — VM112 hook (~½ sprint)
12. VM112: antes DNS final, consultar gate
13. Override auditado super_admin
### Piloto obrigatório
- **1 domínio teste** (não produção crítica)
- Origem: cPanel ou Zimbra conhecido
- Destino: Carbonio VM112 tenant teste
- Só depois: cliente real com legado
---
## API (referência — Spec 013)
| Método | Path |
|--------|------|
| POST | `/api/v1/migration/jobs` |
| POST | `/api/v1/migration/jobs/{id}/preflight` |
| POST | `/api/v1/migration/jobs/{id}/sync` |
| GET | `/api/v1/migration/jobs/{id}/verify` |
| GET | `/api/v1/migration/gate?domain=` |
| POST | `/api/v1/migration/jobs/{id}/approve-gate` |
---
## Fora de escopo desta spec
- Migração no wizard Hero VM112
- Calendário/contactos CardDAV (só e-mail)
- VM123 provisionamento (até volume exigir)
---
## Documentos relacionados
- `specs/013-email-server-migration/spec.md` — spec completa
- `specs/013-email-server-migration/research.md` — ferramentas GitHub
- `specs/013-email-server-migration/plan.md` — ficheiros código
- `specs/013-email-server-migration/quickstart.md` — runbook técnico
- `specs/013-email-server-migration/tasks.md` — checklist T001T040
---
## Critérios de aceite execução VM122
- [ ] imapsync instalado e `--justlogin` OK VM122 → legado + Carbonio
- [ ] Job piloto cPanel/Zimbra → Carbonio sem perda Inbox/Sent
- [ ] Gate bloqueia DNS com sync < 99%
- [ ] Gate libera com aprovação ops_lead + relatório
- [ ] Wizard VM112 respeita gate (ou override auditado)
- [ ] Zero credenciais origem em logs Desk

View file

@ -0,0 +1,155 @@
# Anais de Referência — Serviços MOSP, Orquestração VM122, Purge SSE/Jobs
**Data:** 2026-06-16
**Utilizador:** Roger
**Transcript Cursor:** `ad3c7400-04ce-47bf-8995-2861d54a831b`
**Projeto:** Ligbox Ops Platform · Desk VM122 (`10.10.10.122:8091`) + Wizard VM112 (`10.10.10.112`)
**Chat bruto:** `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.{txt,jsonl}`
---
## 1. Resumo executivo
Sessão focada em **orquestração MOSP no Desk** (não no wizard VM112):
1. Página **Serviços** (ex-Contas/Overview Home) — clientes + tenants de oferta + purge Spec 017.
2. Spec **018** — modelo Pizza as a Service / MOSP / catálogo multi-produto.
3. Purge com painel lateral timeline + **SSE** + **jobs async/polling** (fix 504 / Failed to fetch).
4. Purges testados: `dratcoin.com`, `eplacebets.com` — UI falhou mas backend concluiu.
5. **Fase 3 pendente VM112** — passos Carbonio/CF/Traefik em tempo real dentro do purge.
---
## 2. Specs criadas/actualizadas
| Spec | Path | Estado |
|------|------|--------|
| 017 | `specs/017-vm112-domain-orchestration/spec.md` | Purge domínio — Fase 1 concluída |
| 018 | `specs/018-service-orchestration/spec.md` | MOSP, Pizza as a Service, Fase 1 UI |
---
## 3. UI Desk — menu Serviços
| Item | Valor |
|------|-------|
| Módulo ID | `overview-home` (sem breaking change) |
| Menu | **Serviços** |
| Título | Orquestração de Serviços |
| Subtítulo | Desk VM122 · Orquestração MOSP |
| Layout | 3 colunas: Clientes · Tenants de Oferta · Escopo OPS |
**Ficheiros principais:**
- `frontend/assets/accounts.js``DeskServices`
- `frontend/assets/styles.css``.servicos-*`, `.vm112-purge-drawer`
- `frontend/index.html` → modal + drawer purge
- `api/app/modules/registry.py`
**Regra:** cada oferta MOSP terá **wizard próprio**; VM112 Hero = só e-mail.
---
## 4. API Desk — domínios VM112
| Método | Path | Uso |
|--------|------|-----|
| GET | `/api/v1/vm112/domains` | Lista clientes Fase 1 |
| GET | `/api/v1/vm112/domains/{domain}` | Detalhe modal |
| POST | `/api/v1/vm112/domains/{domain}/purge` | Purge síncrono (legado) |
| POST | `/api/v1/vm112/domains/{domain}/purge/stream` | SSE timeline |
| POST | `/api/v1/vm112/domains/{domain}/purge/jobs` | **Recomendado** — job async |
| GET | `/api/v1/vm112/purge/jobs/{job_id}` | Poll timeline 2s |
**Ficheiros API:**
- `api/app/vm112_domains.py`
- `api/app/vm112_domains_routes.py`
- `api/app/vm112_purge_stream.py`
- `api/app/vm112_purge_jobs.py`
**RBAC:** `super_admin`, `ops_lead` + senha Root no purge.
**Blocklist:** `ligbox.com.br`, `itecnologys.com`
---
## 5. Purge — incidentes e fixes
### 504 Gateway Timeout (~60s)
- **Causa:** nginx proxy timeout 60s; purge VM112 demora minutos.
- **Fix:** `frontend/nginx.conf``proxy_read_timeout 600s`, `proxy_buffering off`.
### Failed to fetch (~79s) via `desk.ligbox.com.br`
- **Causa:** Traefik/SSE ligação longa cortada; browser perde stream.
- **Fix:** purge **async jobs + polling** (pedidos curtos GET a cada 2s).
- **Nota:** purge **concluiu** mesmo com erro UI (`dratcoin`, `eplacebets` sumiram da lista).
### Poll automático página Serviços (piscava)
- **Causa:** `refresh()` 30s re-renderizava com «A carregar…»
- **Fix:** poll silencioso em `renderPage({ poll: true })`
---
## 6. Domínios VM112 (fim de sessão)
Após purges teste, lista típica:
- `betinsport.com`, `diarissima.com`, `ibytera.com`, `itecnologys.com`, `myvexx.com`
- Removidos: `dratcoin.com`, `eplacebets.com` (testes purge)
---
## 7. Deploy
```bash
cd /opt/ligbox-ops-platform
docker-compose -f docker-compose.mvp.yml build api frontend
docker-compose -f docker-compose.mvp.yml up -d api frontend
```
**URLs:**
- Desk: `http://10.10.10.122:8091` / `https://desk.ligbox.com.br`
- API: `http://10.10.10.122:8080`
- Wizard: `https://onboard.ligbox.com.br` (VM112)
**Hard refresh:** Ctrl+Shift+R após deploy.
---
## 8. Reteste E2E wizard e-mail
1. Desk → Serviços → purge domínio teste (se existir)
2. Portal onboard → Self-Service → `/onboard`
3. Domínio → DNS → conta → infra
4. Desk → Serviços → Actualizar → cliente reaparece
---
## 9. Próximo passo — VM112 (Fase 3)
**Não implementado nesta sessão** (sem SSH VM112):
- `domain_orchestration.py` — purge passo a passo com eventos
- `POST /api/admin/domains/{domain}/purge/jobs` na VM112
- Desk proxy eventos VM112 para drawer timeline
**Path produção VM112:** `/opt/ligbox-wizard`
**SSH:** `root@10.10.10.112` (credencial user rule: `@betinplace`)
---
## 10. Canais de arquivo
| Canal | Path |
|-------|------|
| Anais VM122 | `/opt/ligbox-ops-platform/docs/anais-referencia/` |
| Chat bruto projeto | `/opt/ligbox-ops-platform/chat-bruto/` |
| Chat bruto central | `/root/ligbox-ops-platform-chat-bruto/` |
| Obsidian | `/root/obsidian-infra/ligbox-ops-platform/` |
| LAPTOP | `/opt/ligbox-ops-platform/LAPTOP/` |
---
## 11. Decisões Roger (registo)
- MOSP planeado no **Desk VM122**, não na Hero VM112.
- Cada oferta = wizard próprio (Proxmox, servidor físico, etc.).
- Modelo comercial Pizza as a Service documentado na Spec 018.
- Purge Spec 017 mantido; UI evolui (drawer + jobs).

View file

@ -0,0 +1,187 @@
# Anais de Referência — VM123 Finance Stack · FOSS + Odoo + OpenPanel (Spec 024)
**Data:** 2026-06-17
**Utilizador:** Roger
**Projeto:** Ligbox Ops Platform · VM123 (`10.10.10.123`)
**Spec:** `specs/024-openpanel-fossbilling/`
**Deploy:** `deploy/vm123-finance-stack/`
---
## Decisões desta sessão
| Tema | Decisão |
|------|---------|
| Stack | FOSSBilling + Odoo 16 (Docker) + OpenPanel (bare metal) |
| Domínios | **Opção B** — marca `ligbox.com.br` |
| FOSSBilling | `https://financeiro.ligbox.com.br/foss` |
| Odoo 16 | `https://financeiro.ligbox.com.br/odoo` |
| OpenPanel | `https://openpanel.ligbox.com.br` (subdomínio dedicado) |
| Integração | FOSS → OpenPanel via API :2087 (módulo GitHub) |
| Odoo | ERP interno — sync com FOSS/OpenPanel = fase 2 |
---
## Estado VM123 (2026-06-17)
| Item | Status |
|------|--------|
| VM Proxmox 123 `vm123-finance` | ✅ running |
| IP / GW | `10.10.10.123/24` · gw `10.10.10.1` |
| SSH LAN | ✅ `root@10.10.10.123` |
| Bootstrap (swap, fail2ban, UFW) | ✅ |
| DNS fix pós-clone | ✅ `resolv.conf` estático `1.1.1.8` |
| Docker FOSS + Odoo | ✅ ports `:8092` `:8069` |
| Wizard FOSS / Odoo | ⏳ pendente |
| OpenPanel install | ⏳ pendente |
| Traefik CT114 rotas | ⏳ pendente confirmação Roger |
| DNS Cloudflare | ⏳ pendente |
| DNAT SSH WAN `:2523` | ⏳ pendente pfSense |
**OS:** Debian 13 (clone VM121) · **RAM:** 4 GB + swap 2 GB · **Disco:** ~60 GB
---
## Arquitectura
```
Traefik CT114
├── financeiro.ligbox.com.br/foss → VM123:8092 (FOSSBilling)
├── financeiro.ligbox.com.br/odoo → VM123:8069 (Odoo 16)
└── openpanel.ligbox.com.br → VM123:2083 (OpenPanel host)
VM123 Docker: fossbilling + mariadb + odoo + postgres
VM123 host: OpenPanel Enterprise (NÃO Docker)
FOSSBilling ──API :2087──► OpenPanel (provisionar hosting)
Desk VM122 ──links──► financeiro.ligbox.com.br/foss
```
---
## Credenciais
Ficheiro dedicado (mesmo conteúdo):
`CREDENCIAIS_LIGBOX_OPS_AMBIENTES_20260617.txt`
### Desk VM122 — `desk.ligbox.com.br`
| User | Senha | Papel |
|------|-------|-------|
| root | `gsq9qtIUD6SQ45Egm8yP` | super_admin |
| admin | `gsq9qtIUD6SQ45Egm8yP` | ops_lead |
| mini | `gsq9qtIUD6SQ45Egm8yP` | technician |
| noc | `gsq9qtIUD6SQ45Egm8yP` | noc |
SSH Linux VM122: `root` / `805353`
**Nota:** `805353` não funciona no login Desk (rotacionada 2026-06-10).
### VM123 Finance — `10.10.10.123`
| User | Senha | Uso |
|------|-------|-----|
| root | `805353` | SSH |
| admin | `805353` | sudo |
| mini | `805353` | automação |
**Docker `.env`** (`/opt/vm123-finance-stack/.env`):
| Variável | Valor |
|----------|-------|
| FOSS_MARIADB_PASSWORD | `LbFoss9367c416` |
| ODOO_DB_PASSWORD | `LbOdood9ca25c3` |
| FOSSBILLING_URL | `https://financeiro.ligbox.com.br/foss` |
| ODOO_URL | `https://financeiro.ligbox.com.br/odoo` |
| OPENPANEL_DOMAIN | `openpanel.ligbox.com.br` |
FOSSBilling admin / Odoo master: **ainda não configurados** (wizards).
### Wizard VM112 — `10.10.10.112`
| User | Senha |
|------|-------|
| root SSH | `@betinplace` |
API admin key: `ibytera-corp-api-key-change-later`
### Traefik CT114 — `10.10.10.114`
| User | Senha |
|------|-------|
| root SSH | `805353` |
### Proxmox — `10.10.10.2:8006`
| User | Senha |
|------|-------|
| root@pam | `@betinplace` |
SSH host: fechado · API: OK
### pfSense API
| User | Senha |
|------|-------|
| api_cursor | `805353` |
| user_api | `@betinplace` |
URL: `https://firewall.itecnologys.com/api/v2/`
API Key: `7015072cb259165a3ac4b304f556d035`
### Tokens internos Desk (`.env` VM122)
| Item | Valor |
|------|-------|
| JWT_SECRET | `e4b303fe43f8b24b1d924f5ab235d2cea3657b6cd132c925ce60280c64c87ade` |
| OPS_INTERNAL_TOKEN | `128b96e7c12d9b391edbc727880fbdc905b60fa59b52a865` |
| WEBHOOK_SECRET | `ligbox-ops-dev-secret` |
| VM112_ASSIST_TOKEN | `ligbox-desk-assist-7f3a9c2e1b8d4f06` |
| DESK_BOOTSTRAP_PASSWORD | `gsq9qtIUD6SQ45Egm8yP` |
### Cloudflare API
| Conta | Token |
|-------|-------|
| DNS ligbox | `EYH0ZbKTI41f1O0EoW5uxGUUCA3-Fsrt6b4-1xYJ` |
| ligbox.com.br | `UBvRO4URpoGPH-vgjVRfKWOpklvmD9vV9PRX43mP` |
| DNS extra | `cGjq1sABVWq98eiq9DZACleefcVBBGwpR9Foh3X8` |
### Odoo V16 (API externa)
API Key: `813f08e77c858c573e8b7d10d1304dac9e073c8e`
---
## Ficheiros alterados
| Path | Alteração |
|------|-----------|
| `specs/024-openpanel-fossbilling/spec.md` | Domínios ligbox.com.br |
| `deploy/vm123-finance-stack/.env.example` | URLs ligbox |
| `deploy/vm123-finance-stack/traefik-routes-snippet.yml` | Hosts ligbox |
| `deploy/vm123-finance-stack/install-openpanel.sh` | Domínio default ligbox |
| `deploy/vm123-finance-stack/README.md` | URLs + DNS |
| VM123 `/opt/vm123-finance-stack/.env` | Aplicado em produção |
---
## Próximos passos
1. Wizards FOSSBilling + Odoo na VM123
2. `install-openpanel.sh` (bare metal)
3. `setup-foss-openpanel-module.sh`
4. DNS: `financeiro.ligbox.com.br` + `openpanel.ligbox.com.br`
5. Traefik CT114 — merge `traefik-routes-snippet.yml`
6. DNAT SSH `:2523` pfSense → VM123:22
7. Desk Spec 023 — links financeiro
---
## Canais de cópia
```
/opt/ligbox-ops-platform/specs/024-openpanel-fossbilling/
/opt/ligbox-ops-platform/docs/anais-referencia/
/opt/ligbox-ops-platform/LAPTOP/
/root/obsidian-infra/ligbox-ops-platform/
```

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

123
LAPTOP/INDICE_ANAIS.md Normal file
View file

@ -0,0 +1,123 @@
# Índice — Anais de Referência (Ligbox Ops Platform)
**Atualizado:** 2026-06-17
**Responsável:** Roger / Cursor Agent
**VM122:** `10.10.10.122` · SSH WAN `:2522`
---
## Formato
| Tipo | Extensão | Conteúdo |
|------|----------|----------|
| **Aspectos** | `*_ASPECTOS.md` | Decisões, arquitectura, ficheiros, comandos, pendências |
| **Chat bruto** | `*.txt` | Transcript legível (user + assistant + ferramentas) |
| **Chat original** | `*.jsonl` | Transcript Cursor integral |
---
## Entradas
### 2026-06-10 — Spec 013 Migração de E-mail entre Servidores
| Ficheiro | Descrição |
|----------|-----------|
| `specs/013-email-server-migration/spec.md` | Spec completa — gate DNS, fases, API |
| `specs/013-email-server-migration/research.md` | Ferramentas: imapsync, readpst, imap-upload, TGZ |
| `specs/013-email-server-migration/plan.md` | Módulo técnico API + worker |
| `specs/013-email-server-migration/infrastructure.md` | VM/recursos — **futuro, não hoje** |
| `20260610_SPEC_013_EMAIL_MIGRATION.md` | Cópia spec nos anais |
**Regra:** migrar e validar **antes** de virar MX/DNS.
---
---
### 2026-06-17 — VM123 Finance Stack · FOSS + Odoo + OpenPanel (Spec 024)
| Ficheiro | Descrição |
|----------|-----------|
| `specs/024-openpanel-fossbilling/spec.md` | Spec completa — stack financeiro VM123 |
| `specs/024-openpanel-fossbilling/tasks.md` | Checklist deploy |
| `20260617_VM123_FINANCE_STACK_ASPECTOS.md` | Decisões, domínios ligbox, estado VM123 |
| `CREDENCIAIS_LIGBOX_OPS_AMBIENTES_20260617.txt` | Senhas todos os ambientes |
| `README_COPIAR_ANAIS_VM123_FINANCE_20260617.txt` | Guia cópia LAPTOP/Obsidian |
**Domínios (Opção B):**
- `financeiro.ligbox.com.br/foss` — FOSSBilling
- `financeiro.ligbox.com.br/odoo` — Odoo 16
- `openpanel.ligbox.com.br` — OpenPanel
**Pendente:** wizards FOSS/Odoo, install OpenPanel, Traefik, DNS, DNAT :2523
---
### 2026-06-16 — Serviços MOSP · Orquestração · Purge SSE/Jobs
| Ficheiro | Descrição |
|----------|-----------|
| `20260616_SERVICOS_ORQUESTRACAO_PURGE_ASPECTOS.md` | Aspectos completos da sessão |
| `chat-bruto/CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.txt` | Chat bruto legível |
| `chat-bruto/CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.jsonl` | JSONL original |
| `specs/017-vm112-domain-orchestration/spec.md` | Purge domínio VM112 |
| `specs/018-service-orchestration/spec.md` | MOSP / Pizza as a Service |
**Transcript:** `ad3c7400-04ce-47bf-8995-2861d54a831b`
**Temas:**
- Página **Serviços** (tenants de oferta, não na Hero VM112)
- Spec 018 MOSP + modelo comercial
- Purge drawer timeline + SSE + **jobs async** (fix 504/Failed to fetch)
- Purges teste dratcoin/eplacebets
- **Pendente VM112 Fase 3** — passos purge em tempo real
---
### 2026-06-10 — Overview + DNS Cloudflare + UI Desk
| Ficheiro | Descrição |
|----------|-----------|
| `20260610_OVERVIEW_DNS_UI_ASPECTOS.md` | Aspectos completos da sessão |
| `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.txt` | Chat bruto legível |
| `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.jsonl` | JSONL original |
**Transcript:** `161d3d86-8ce8-4a2d-86f7-424b69111cb3`
**Temas:**
- Menu lateral SVG (referência `menu lateral__dashboard.png`)
- Overview clássico — cards por tenant, modal domínio
- Overview Home estilo Cloudflare (menu novo, original preservado)
- API DNS Cloudflare + card na linha Security/Performance/Activity
- Fix exibição DNS (fetch independente do scorecard)
- Deploy Docker rebuild frontend/api
---
## Entradas anteriores (chat bruto)
Ver `INDICE_MODELAGEM_BRUTA.txt` em `/root/ligbox-ops-platform-chat-bruto/`:
- `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_20260604` — visão inicial, arquitectura
- `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_VM122_SPEC_20260608` — Spec Kit, webhooks, Wazuh
---
## Canais espelhados
```
/opt/ligbox-ops-platform/docs/anais-referencia/
/opt/ligbox-ops-platform/chat-bruto/
/root/ligbox-ops-platform-chat-bruto/
/root/ligbox-ops-platform-chat-bruto/anais-referencia/
/opt/ligbox-ops-platform/LAPTOP/
```
---
## Regenerar
```bash
python3 /opt/ligbox-ops-platform/scripts/export-chat-bruto-standalone.py \
<caminho.jsonl> CHAT_BRUTO_<NOME>_<YYYYMMDD> <transcript-uuid>
```

View file

@ -0,0 +1,93 @@
# Spec 024 — Card cliente → FOSS → OpenPanel (provisionamento)
**Roger · 2026-06-17**
## O teu raciocínio está correto
1. **Card do cliente (Desk / portal)** — recolhe dados mínimos do comprador.
2. **FOSSBilling** — cria cliente + pedido + activa produto hosting.
3. **OpenPanel** — recebe API call do FOSS (`createAccount`) e cria user hosting.
4. **pfSense****não** cria conta; só encaminha tráfego WAN → Traefik → VM123.
```
Internet → 95.216.14.146 (pfSense WAN)
→ NAT :80/:443 → 10.10.10.114 (Traefik)
→ financeiro.ligbox.com.br/foss|/odoo → 10.10.10.123
→ openpanel.ligbox.com.br → 10.10.10.123:2083
```
**NAT pfSense já existente (não precisa duplicar):**
| Regra | WAN | Destino |
|-------|-----|---------|
| Traefik HTTP | 80 | 10.10.10.114 |
| Traefik HTTPS | 443 | 10.10.10.114 |
Novos hostnames só precisam de **DNS Cloudflare** → mesmo IP público.
---
## Campos obrigatórios no card (→ FOSS → OpenPanel)
| Campo no card | Vai para FOSSBilling | Vai para OpenPanel API | Notas |
|---------------|----------------------|------------------------|-------|
| **email** | Cliente `email` | `email` | Login/recuperação |
| **nome / empresa** | Cliente `first_name` / company | — | Facturação |
| **domínio** | Opcional no produto | gera `username` (7 chars + dígito) | ex: `cliente1.com` → user `cliente1x` |
| **senha painel** | Order / hosting password | `password` | Senha OpenPanel user |
| **plano** | Product / `plan_name` | `plan_name` | **Deve coincidir** com plano OpenPanel |
| **CPF/CNPJ** | Cliente custom field | — | Fiscal (Odoo fase 2) |
| **telefone** | Cliente `phone` | — | Suporte |
### Plano OpenPanel criado (VM123)
| name | id | Uso |
|------|-----|-----|
| `ligbox-site-cms` | 3 | Site/CMS Spec 018 |
| `Standard plan` | 1 | Testes |
| `Developer Plus` | 2 | Maior |
**FOSS product** deve usar `plan_name` = `ligbox-site-cms` (exacto).
---
## Config FOSSBilling → Server OpenPanel
Admin FOSS → **System → Hosting plans → New server**
| Campo | Valor |
|-------|-------|
| Manager | OpenPanel |
| Hostname | `10.10.10.123` |
| Port | `2087` |
| Secure | Yes (HTTPS) |
| Username | `ligboxadmin` |
| Password | `LbOpen805353` |
Test connection → depois associar produto hosting ao server + plano `ligbox-site-cms`.
---
## Fluxo automático (pedido pago)
```
Card cliente (email, domínio, plano, senha)
→ FOSSBilling: create client + order
→ FOSSBilling: activate hosting
→ OpenPanel.php: POST /api/users
{ email, username, password, plan_name }
→ OpenPanel: conta hosting criada
→ Email cliente com URL openpanel.ligbox.com.br
```
---
## O que o Desk precisa (Spec 023 fase 2)
No card **Serviços / Site CMS**:
- `client_email` *
- `client_name` *
- `domain` * (para username OpenPanel)
- `hosting_plan` * (dropdown: ligbox-site-cms)
- `panel_password` * (ou gerar)
- `foss_client_id` (após sync)
- `openpanel_username` (read-only após provision)

View file

@ -0,0 +1,25 @@
ANAIS DE REFERÊNCIA — copiar para C:\LAPTOP no Windows
========================================================
Data: 2026-06-10
Transcript: 161d3d86-8ce8-4a2d-86f7-424b69111cb3
Sessão: Overview + DNS Cloudflare + UI Desk
Host VM122: 95.216.14.146 · Porta SSH: 2522
Pasta servidor: /root/ligbox-ops-platform-chat-bruto/
Ficheiros principais:
anais-referencia/20260610_OVERVIEW_DNS_UI_ASPECTOS.md
anais-referencia/INDICE_ANAIS.md
CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.txt
CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.jsonl
INDICE_MODELAGEM_BRUTA.txt
Copiar para C:\LAPTOP (PowerShell):
mkdir C:\LAPTOP\projetos\ligbox-ops-platform-anais 2>$null
scp -P 2522 -r root@95.216.14.146:/root/ligbox-ops-platform-chat-bruto/anais-referencia C:\LAPTOP\projetos\ligbox-ops-platform-anais\
scp -P 2522 root@95.216.14.146:/root/ligbox-ops-platform-chat-bruto/CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.txt C:\LAPTOP\projetos\ligbox-ops-platform-anais\
scp -P 2522 root@95.216.14.146:/root/ligbox-ops-platform-chat-bruto/CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.jsonl C:\LAPTOP\projetos\ligbox-ops-platform-anais\
scp -P 2522 root@95.216.14.146:/root/ligbox-ops-platform-chat-bruto/INDICE_MODELAGEM_BRUTA.txt C:\LAPTOP\projetos\ligbox-ops-platform-anais\
Alternativa projeto:
scp -P 2522 -r root@95.216.14.146:/opt/ligbox-ops-platform/docs/anais-referencia C:\LAPTOP\projetos\ligbox-ops-platform-anais\

View file

@ -0,0 +1,41 @@
# README — Copiar para LAPTOP / Obsidian (2026-06-16)
**Sessão:** Serviços MOSP · Orquestração VM122 · Purge SSE/Jobs
**Roger**
## Ficheiros desta sessão
| Ficheiro | Descrição |
|----------|-----------|
| `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.txt` | Chat integral legível |
| `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.jsonl` | Transcript Cursor original |
| `20260616_SERVICOS_ORQUESTRACAO_PURGE_ASPECTOS.md` | Decisões, APIs, fixes, próximos passos VM112 |
## Onde estão (VM122)
```
/opt/ligbox-ops-platform/chat-bruto/
/opt/ligbox-ops-platform/docs/anais-referencia/
/opt/ligbox-ops-platform/LAPTOP/
/root/ligbox-ops-platform-chat-bruto/
/root/obsidian-infra/ligbox-ops-platform/
```
## Transcript Cursor
`ad3c7400-04ce-47bf-8995-2861d54a831b`
## Continuar na VM112
1. Ler `20260616_*_ASPECTOS.md` secção 9 (Fase 3 purge passo a passo)
2. Path: `/opt/ligbox-wizard/backend/app/services/domain_orchestration.py`
3. Specs: `017`, `018` em `/opt/ligbox-ops-platform/specs/`
## Regenerar chat bruto
```bash
python3 /opt/ligbox-ops-platform/scripts/export-chat-bruto-standalone.py \
/root/.cursor/projects/1781626937265/agent-transcripts/ad3c7400-04ce-47bf-8995-2861d54a831b/ad3c7400-04ce-47bf-8995-2861d54a831b.jsonl \
CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616 \
ad3c7400-04ce-47bf-8995-2861d54a831b
```

View file

@ -0,0 +1,39 @@
# README — Copiar para LAPTOP / Obsidian (2026-06-17)
**Sessão:** VM123 Finance Stack · Spec 024 · Domínios ligbox · Credenciais
**Roger**
## Ficheiros desta sessão
| Ficheiro | Descrição |
|----------|-----------|
| `specs/024-openpanel-fossbilling/spec.md` | Spec FOSS + Odoo + OpenPanel |
| `specs/024-openpanel-fossbilling/tasks.md` | Checklist deploy |
| `20260617_VM123_FINANCE_STACK_ASPECTOS.md` | Decisões, estado, arquitectura |
| `CREDENCIAIS_LIGBOX_OPS_AMBIENTES_20260617.txt` | Senhas todos os ambientes |
## Onde estão (VM122)
```
/opt/ligbox-ops-platform/specs/024-openpanel-fossbilling/
/opt/ligbox-ops-platform/docs/anais-referencia/
/opt/ligbox-ops-platform/LAPTOP/
/root/obsidian-infra/ligbox-ops-platform/
```
## URLs finais (Opção B)
- FOSSBilling: https://financeiro.ligbox.com.br/foss
- Odoo 16: https://financeiro.ligbox.com.br/odoo
- OpenPanel: https://openpanel.ligbox.com.br
## Continuar deploy VM123
```bash
ssh root@10.10.10.123
cd /opt/vm123-finance-stack
# Wizard FOSS: http://10.10.10.123:8092
# Wizard Odoo: http://10.10.10.123:8069
bash install-openpanel.sh
bash setup-foss-openpanel-module.sh
```

View file

@ -0,0 +1,231 @@
# Anais de Referência — Overview, DNS Cloudflare e UI Desk
**Data:** 2026-06-10
**Utilizador:** Roger
**Transcript Cursor:** `161d3d86-8ce8-4a2d-86f7-424b69111cb3`
**Projeto:** Ligbox Ops Platform · VM122 (`10.10.10.122:8080`)
**Chat bruto:** `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.{txt,jsonl}`
---
## 1. Resumo executivo
Sessão focada em **UI/UX do Support Desk** e **Audit Overview**:
1. Menu lateral redesenhado com **ícones SVG inline** (não imagens recortadas).
2. **Overview clássico** mantido — cards = **tenants** (não empresas individuais).
3. Modal de detalhe por tenant com lista de domínios, timeline, checks, IP de acesso.
4. Novo menu **Overview Home** (estilo Cloudflare) — **sem apagar** o Overview original.
5. Card **Apontamentos DNS (Cloudflare)** via API — integrado na **linha de métricas** (Security · Performance · Activity · DNS).
6. Correção de bug: DNS descartado quando scorecard falhava (`Promise.all`).
---
## 2. Infraestrutura e deploy
| Item | Valor |
|------|-------|
| Host | VM122 `ligbox-ops` |
| URL Desk | `http://10.10.10.122:8080` |
| Compose | `docker-compose.mvp.yml` |
| Frontend | container `ligbox-ops-platform_frontend_1` (nginx) |
| API | container `ligbox-ops-platform_api_1` |
| Código ativo | `./frontend/` e `./api/` (não a raiz `/opt/ligbox-ops-platform/index.html`) |
**Rebuild obrigatório após alterações frontend/API:**
```bash
cd /opt/ligbox-ops-platform
docker-compose -f docker-compose.mvp.yml up -d --build frontend
# Se API mudou:
docker-compose -f docker-compose.mvp.yml up -d --build api frontend
```
**Cache bust:** `index.html` usa query `?v=20260610dns3` em `styles.css` e `app.js`.
---
## 3. Menu lateral
### Pedido Roger
- Referência visual: `frontend/menu lateral__dashboard.png`
- Ícones **separados**, construídos como elementos (SVG), não PNG recortado.
- Espaçamento vertical **compacto** sem reduzir tamanho dos ícones.
- Modelo premium dos ícones: aceitável “por hora”.
### Implementação
- SVG symbols em `frontend/index.html` (`#icon-dashboard`, `#icon-overview`, etc.)
- CSS em `frontend/assets/styles.css` (`.nav-icon-wrap`, `.nav-icon-svg`)
- Variáveis: `--sidebar-w`, `--nav-icon-col`, `min-height` dos botões reduzido progressivamente
### Ficheiros
- `frontend/index.html`
- `frontend/assets/styles.css`
---
## 4. Overview clássico (mantido)
### Modelo de dados
- **1 card = 1 tenant** (ex.: VM112, VM104)
- 25 empresas em onboarding no mesmo tenant → **1 card** com domínios agregados
- Resposta esperada Roger: 2 cards para tenants distintos, não 25 cards
### Modal tenant (`openOverviewModal`)
- Endpoint: `GET /api/v1/audit/tenants/{id}/details`
- Lista domínios clicáveis
- Resumo: total, em execução, concluídos, falharam, com erros
### Modal domínio (`openOverviewDomainDetail`)
- Scorecard: `GET /api/v1/audit/tenants/{id}/scorecard?domain=...`
- Timeline webhook com `client_ip`, email, timestamps
- Checks de auditoria
- Ticket associado (abrir em Tickets)
- **DNS Cloudflare** (secção dedicada)
### Ficheiros backend
- `api/app/audit_store.py``tenant_details()`, scorecard
- `api/app/main.py` — rotas audit + webhook grava `client_ip` / `ingress_client_ip`
### Ficheiros frontend
- `frontend/assets/app.js``openOverviewModal`, `renderOverviewModalList`, `openOverviewDomainDetail`
- `frontend/index.html``#overview-modal`
- `frontend/assets/styles.css``.overview-domain-row`, `.modal-panel-lg`
---
## 5. Overview Home (novo — Cloudflare-style)
### Menu
- Item **Overview Home** com badge `novo`
- View: `overview-home` (`#view-overview-home`)
- Overview original **intacto** em `overview`
### Layout
- Toolbar período: 24h / 7d / 30d
- **Linha de métricas (4 cards):**
1. Security — domínios com alertas, eventos Wazuh
2. Performance — checks pass %, degraded/critical
3. Activity — onboarding em execução, webhooks
4. **Apontamentos DNS (Cloudflare)** — card interativo
- Painéis: Domains, Audit trail, Infra nodes, Next steps
### Interação DNS na linha de processos
- Clique em domínio (Domains ou Audit trail) → carrega DNS no **4.º card**
- Funções: `showOverviewHomeDnsPanel()`, `htmlCloudflareDnsCardInline()`
- Estado: `state.overviewHomeDnsDomain`
### Ficheiros
- `frontend/assets/app.js``renderOverviewHome()`, `buildOverviewHomeTrail()`
- `frontend/assets/styles.css``.cf-home*`, `.cf-metrics-row` (4 colunas), `.cf-dns-metric-card`
---
## 6. DNS Cloudflare
### API backend
- Módulo: `api/app/cloudflare_dns.py`
- Função: `fetch_domain_dns(domain, email_service=...)`
- Endpoint: `GET /api/v1/dns/cloudflare/records?domain=...&email_service=true|false`
- Permissão: `can_read_cloudflare_dns` em `api/app/permissions.py`
- Roles: `super_admin`, `ops_lead`, `technician`, `noc`
### Tokens (`.env`)
```
CLOUDFLARE_API_TOKENS=EYH0ZbKTI41f1O0EoW5uxGUUCA3-Fsrt6b4-1xYJ,UBvRO4URpoGPH-vgjVRfKWOpklvmD9vV9PRX43mP
```
### Classificação de registos
- `mx`, `spf`, `dkim`, `dmarc`, `mail-host`, `autodiscover`, `mail-alias`, `other`
- Filtro por domínio na zona pai Cloudflare
- `email_service=true` quando tenant_id=1 ou etapa funil de e-mail
### Testes validados (API via nginx :8080)
| Domínio | Resultado |
|---------|-----------|
| `itecnologys.com` | 56 registos |
| `ligbox.com.br` | 18 registos |
| `diarissima.com` | Zona não encontrada (sem token/zona) |
### Bug corrigido (2026-06-10)
- **Antes:** `Promise.all([scorecard, dns])` — falha do scorecard descartava DNS
- **Depois:** `fetchCloudflareDns()` independente; sempre exibe card (dados ou erro)
### Teste rápido
```bash
TOKEN=$(curl -s -X POST "http://10.10.10.122:8080/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"username":"root","password":"<senha>"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
curl -s "http://10.10.10.122:8080/api/v1/dns/cloudflare/records?domain=itecnologys.com&email_service=true" \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool | head -30
```
---
## 7. Mapa de ficheiros alterados (sessão)
| Área | Ficheiro | Alteração |
|------|----------|-----------|
| Frontend | `frontend/index.html` | SVG menu, modal overview, view overview-home, cache bust |
| Frontend | `frontend/assets/app.js` | Menu, overview modal, overview home, DNS fetch/card |
| Frontend | `frontend/assets/styles.css` | Sidebar, cf-home, DNS tables, metrics 4-col |
| API | `api/app/main.py` | Rota DNS Cloudflare, webhook IP |
| API | `api/app/cloudflare_dns.py` | **Novo** — integração Cloudflare |
| API | `api/app/audit_store.py` | tenant details, scorecard |
| API | `api/app/permissions.py` | `can_read_cloudflare_dns` |
| Config | `.env` | `CLOUDFLARE_API_TOKENS` |
---
## 8. Decisões e pendências
| Tema | Estado |
|------|--------|
| Overview vs Overview Home | Roger a decidir qual manter |
| Ícones premium (referência PNG) | Aceitável por hora; melhorar depois |
| Domínios sem zona CF | Adicionar tokens/zones (`diarissima.com`, `*.ligbox`) |
| AUD collectors DNS | Parcial no backlog |
---
## 9. Pedidos Roger (cronologia)
1. Menu lateral — ícones recortados/pequenos → SVG separado
2. “Não mudou nada” → rebuild Docker
3. Compactar espaço vertical do menu
4. Cards Overview = tenants?
5. 25 empresas → quantos cards? → 1 por tenant
6. Modal com domínios, timestamps, erros, IP
7. Tela estilo Cloudflare para Audit → Overview Home
8. Criar Overview Home sem destruir atual
9. Card DNS Cloudflare para gestão de domínio/e-mail
10. “Não está exibindo” → fix Promise.all + painel visível
11. Colocar card DNS na linha Security/Performance/Activity
12. **Salvar aspectos + chat bruto nos anais de referência** (este documento)
---
## 10. Canais de arquivo (chat bruto + anais)
| Canal | Caminho |
|-------|---------|
| VM122 principal | `/root/ligbox-ops-platform-chat-bruto/` |
| Anais VM122 | `/root/ligbox-ops-platform-chat-bruto/anais-referencia/` |
| Projeto | `/opt/ligbox-ops-platform/docs/anais-referencia/` |
| Chat bruto projeto | `/opt/ligbox-ops-platform/chat-bruto/` |
| LAPTOP (staging scp) | `/opt/ligbox-ops-platform/LAPTOP/` |
| Obsidian VM112 | `/root/obsidian-infra/ligbox-ops-platform/` (se existir) |
**Regenerar chat bruto:**
```bash
python3 /opt/ligbox-ops-platform/scripts/export-chat-bruto-standalone.py \
/root/.cursor/projects/1781094241105/agent-transcripts/161d3d86-8ce8-4a2d-86f7-424b69111cb3/161d3d86-8ce8-4a2d-86f7-424b69111cb3.jsonl \
CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610 \
161d3d86-8ce8-4a2d-86f7-424b69111cb3
```
---
*Documento gerado automaticamente na sessão Cursor — Ligbox Ops Platform.*

View file

@ -0,0 +1,155 @@
# Anais de Referência — Serviços MOSP, Orquestração VM122, Purge SSE/Jobs
**Data:** 2026-06-16
**Utilizador:** Roger
**Transcript Cursor:** `ad3c7400-04ce-47bf-8995-2861d54a831b`
**Projeto:** Ligbox Ops Platform · Desk VM122 (`10.10.10.122:8091`) + Wizard VM112 (`10.10.10.112`)
**Chat bruto:** `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.{txt,jsonl}`
---
## 1. Resumo executivo
Sessão focada em **orquestração MOSP no Desk** (não no wizard VM112):
1. Página **Serviços** (ex-Contas/Overview Home) — clientes + tenants de oferta + purge Spec 017.
2. Spec **018** — modelo Pizza as a Service / MOSP / catálogo multi-produto.
3. Purge com painel lateral timeline + **SSE** + **jobs async/polling** (fix 504 / Failed to fetch).
4. Purges testados: `dratcoin.com`, `eplacebets.com` — UI falhou mas backend concluiu.
5. **Fase 3 pendente VM112** — passos Carbonio/CF/Traefik em tempo real dentro do purge.
---
## 2. Specs criadas/actualizadas
| Spec | Path | Estado |
|------|------|--------|
| 017 | `specs/017-vm112-domain-orchestration/spec.md` | Purge domínio — Fase 1 concluída |
| 018 | `specs/018-service-orchestration/spec.md` | MOSP, Pizza as a Service, Fase 1 UI |
---
## 3. UI Desk — menu Serviços
| Item | Valor |
|------|-------|
| Módulo ID | `overview-home` (sem breaking change) |
| Menu | **Serviços** |
| Título | Orquestração de Serviços |
| Subtítulo | Desk VM122 · Orquestração MOSP |
| Layout | 3 colunas: Clientes · Tenants de Oferta · Escopo OPS |
**Ficheiros principais:**
- `frontend/assets/accounts.js``DeskServices`
- `frontend/assets/styles.css``.servicos-*`, `.vm112-purge-drawer`
- `frontend/index.html` → modal + drawer purge
- `api/app/modules/registry.py`
**Regra:** cada oferta MOSP terá **wizard próprio**; VM112 Hero = só e-mail.
---
## 4. API Desk — domínios VM112
| Método | Path | Uso |
|--------|------|-----|
| GET | `/api/v1/vm112/domains` | Lista clientes Fase 1 |
| GET | `/api/v1/vm112/domains/{domain}` | Detalhe modal |
| POST | `/api/v1/vm112/domains/{domain}/purge` | Purge síncrono (legado) |
| POST | `/api/v1/vm112/domains/{domain}/purge/stream` | SSE timeline |
| POST | `/api/v1/vm112/domains/{domain}/purge/jobs` | **Recomendado** — job async |
| GET | `/api/v1/vm112/purge/jobs/{job_id}` | Poll timeline 2s |
**Ficheiros API:**
- `api/app/vm112_domains.py`
- `api/app/vm112_domains_routes.py`
- `api/app/vm112_purge_stream.py`
- `api/app/vm112_purge_jobs.py`
**RBAC:** `super_admin`, `ops_lead` + senha Root no purge.
**Blocklist:** `ligbox.com.br`, `itecnologys.com`
---
## 5. Purge — incidentes e fixes
### 504 Gateway Timeout (~60s)
- **Causa:** nginx proxy timeout 60s; purge VM112 demora minutos.
- **Fix:** `frontend/nginx.conf``proxy_read_timeout 600s`, `proxy_buffering off`.
### Failed to fetch (~79s) via `desk.ligbox.com.br`
- **Causa:** Traefik/SSE ligação longa cortada; browser perde stream.
- **Fix:** purge **async jobs + polling** (pedidos curtos GET a cada 2s).
- **Nota:** purge **concluiu** mesmo com erro UI (`dratcoin`, `eplacebets` sumiram da lista).
### Poll automático página Serviços (piscava)
- **Causa:** `refresh()` 30s re-renderizava com «A carregar…»
- **Fix:** poll silencioso em `renderPage({ poll: true })`
---
## 6. Domínios VM112 (fim de sessão)
Após purges teste, lista típica:
- `betinsport.com`, `diarissima.com`, `ibytera.com`, `itecnologys.com`, `myvexx.com`
- Removidos: `dratcoin.com`, `eplacebets.com` (testes purge)
---
## 7. Deploy
```bash
cd /opt/ligbox-ops-platform
docker-compose -f docker-compose.mvp.yml build api frontend
docker-compose -f docker-compose.mvp.yml up -d api frontend
```
**URLs:**
- Desk: `http://10.10.10.122:8091` / `https://desk.ligbox.com.br`
- API: `http://10.10.10.122:8080`
- Wizard: `https://onboard.ligbox.com.br` (VM112)
**Hard refresh:** Ctrl+Shift+R após deploy.
---
## 8. Reteste E2E wizard e-mail
1. Desk → Serviços → purge domínio teste (se existir)
2. Portal onboard → Self-Service → `/onboard`
3. Domínio → DNS → conta → infra
4. Desk → Serviços → Actualizar → cliente reaparece
---
## 9. Próximo passo — VM112 (Fase 3)
**Não implementado nesta sessão** (sem SSH VM112):
- `domain_orchestration.py` — purge passo a passo com eventos
- `POST /api/admin/domains/{domain}/purge/jobs` na VM112
- Desk proxy eventos VM112 para drawer timeline
**Path produção VM112:** `/opt/ligbox-wizard`
**SSH:** `root@10.10.10.112` (credencial user rule: `@betinplace`)
---
## 10. Canais de arquivo
| Canal | Path |
|-------|------|
| Anais VM122 | `/opt/ligbox-ops-platform/docs/anais-referencia/` |
| Chat bruto projeto | `/opt/ligbox-ops-platform/chat-bruto/` |
| Chat bruto central | `/root/ligbox-ops-platform-chat-bruto/` |
| Obsidian | `/root/obsidian-infra/ligbox-ops-platform/` |
| LAPTOP | `/opt/ligbox-ops-platform/LAPTOP/` |
---
## 11. Decisões Roger (registo)
- MOSP planeado no **Desk VM122**, não na Hero VM112.
- Cada oferta = wizard próprio (Proxmox, servidor físico, etc.).
- Modelo comercial Pizza as a Service documentado na Spec 018.
- Purge Spec 017 mantido; UI evolui (drawer + jobs).

View file

@ -0,0 +1,187 @@
# Anais de Referência — VM123 Finance Stack · FOSS + Odoo + OpenPanel (Spec 024)
**Data:** 2026-06-17
**Utilizador:** Roger
**Projeto:** Ligbox Ops Platform · VM123 (`10.10.10.123`)
**Spec:** `specs/024-openpanel-fossbilling/`
**Deploy:** `deploy/vm123-finance-stack/`
---
## Decisões desta sessão
| Tema | Decisão |
|------|---------|
| Stack | FOSSBilling + Odoo 16 (Docker) + OpenPanel (bare metal) |
| Domínios | **Opção B** — marca `ligbox.com.br` |
| FOSSBilling | `https://financeiro.ligbox.com.br/foss` |
| Odoo 16 | `https://financeiro.ligbox.com.br/odoo` |
| OpenPanel | `https://openpanel.ligbox.com.br` (subdomínio dedicado) |
| Integração | FOSS → OpenPanel via API :2087 (módulo GitHub) |
| Odoo | ERP interno — sync com FOSS/OpenPanel = fase 2 |
---
## Estado VM123 (2026-06-17)
| Item | Status |
|------|--------|
| VM Proxmox 123 `vm123-finance` | ✅ running |
| IP / GW | `10.10.10.123/24` · gw `10.10.10.1` |
| SSH LAN | ✅ `root@10.10.10.123` |
| Bootstrap (swap, fail2ban, UFW) | ✅ |
| DNS fix pós-clone | ✅ `resolv.conf` estático `1.1.1.8` |
| Docker FOSS + Odoo | ✅ ports `:8092` `:8069` |
| Wizard FOSS / Odoo | ⏳ pendente |
| OpenPanel install | ⏳ pendente |
| Traefik CT114 rotas | ⏳ pendente confirmação Roger |
| DNS Cloudflare | ⏳ pendente |
| DNAT SSH WAN `:2523` | ⏳ pendente pfSense |
**OS:** Debian 13 (clone VM121) · **RAM:** 4 GB + swap 2 GB · **Disco:** ~60 GB
---
## Arquitectura
```
Traefik CT114
├── financeiro.ligbox.com.br/foss → VM123:8092 (FOSSBilling)
├── financeiro.ligbox.com.br/odoo → VM123:8069 (Odoo 16)
└── openpanel.ligbox.com.br → VM123:2083 (OpenPanel host)
VM123 Docker: fossbilling + mariadb + odoo + postgres
VM123 host: OpenPanel Enterprise (NÃO Docker)
FOSSBilling ──API :2087──► OpenPanel (provisionar hosting)
Desk VM122 ──links──► financeiro.ligbox.com.br/foss
```
---
## Credenciais
Ficheiro dedicado (mesmo conteúdo):
`CREDENCIAIS_LIGBOX_OPS_AMBIENTES_20260617.txt`
### Desk VM122 — `desk.ligbox.com.br`
| User | Senha | Papel |
|------|-------|-------|
| root | `gsq9qtIUD6SQ45Egm8yP` | super_admin |
| admin | `gsq9qtIUD6SQ45Egm8yP` | ops_lead |
| mini | `gsq9qtIUD6SQ45Egm8yP` | technician |
| noc | `gsq9qtIUD6SQ45Egm8yP` | noc |
SSH Linux VM122: `root` / `805353`
**Nota:** `805353` não funciona no login Desk (rotacionada 2026-06-10).
### VM123 Finance — `10.10.10.123`
| User | Senha | Uso |
|------|-------|-----|
| root | `805353` | SSH |
| admin | `805353` | sudo |
| mini | `805353` | automação |
**Docker `.env`** (`/opt/vm123-finance-stack/.env`):
| Variável | Valor |
|----------|-------|
| FOSS_MARIADB_PASSWORD | `LbFoss9367c416` |
| ODOO_DB_PASSWORD | `LbOdood9ca25c3` |
| FOSSBILLING_URL | `https://financeiro.ligbox.com.br/foss` |
| ODOO_URL | `https://financeiro.ligbox.com.br/odoo` |
| OPENPANEL_DOMAIN | `openpanel.ligbox.com.br` |
FOSSBilling admin / Odoo master: **ainda não configurados** (wizards).
### Wizard VM112 — `10.10.10.112`
| User | Senha |
|------|-------|
| root SSH | `@betinplace` |
API admin key: `ibytera-corp-api-key-change-later`
### Traefik CT114 — `10.10.10.114`
| User | Senha |
|------|-------|
| root SSH | `805353` |
### Proxmox — `10.10.10.2:8006`
| User | Senha |
|------|-------|
| root@pam | `@betinplace` |
SSH host: fechado · API: OK
### pfSense API
| User | Senha |
|------|-------|
| api_cursor | `805353` |
| user_api | `@betinplace` |
URL: `https://firewall.itecnologys.com/api/v2/`
API Key: `7015072cb259165a3ac4b304f556d035`
### Tokens internos Desk (`.env` VM122)
| Item | Valor |
|------|-------|
| JWT_SECRET | `e4b303fe43f8b24b1d924f5ab235d2cea3657b6cd132c925ce60280c64c87ade` |
| OPS_INTERNAL_TOKEN | `128b96e7c12d9b391edbc727880fbdc905b60fa59b52a865` |
| WEBHOOK_SECRET | `ligbox-ops-dev-secret` |
| VM112_ASSIST_TOKEN | `ligbox-desk-assist-7f3a9c2e1b8d4f06` |
| DESK_BOOTSTRAP_PASSWORD | `gsq9qtIUD6SQ45Egm8yP` |
### Cloudflare API
| Conta | Token |
|-------|-------|
| DNS ligbox | `EYH0ZbKTI41f1O0EoW5uxGUUCA3-Fsrt6b4-1xYJ` |
| ligbox.com.br | `UBvRO4URpoGPH-vgjVRfKWOpklvmD9vV9PRX43mP` |
| DNS extra | `cGjq1sABVWq98eiq9DZACleefcVBBGwpR9Foh3X8` |
### Odoo V16 (API externa)
API Key: `813f08e77c858c573e8b7d10d1304dac9e073c8e`
---
## Ficheiros alterados
| Path | Alteração |
|------|-----------|
| `specs/024-openpanel-fossbilling/spec.md` | Domínios ligbox.com.br |
| `deploy/vm123-finance-stack/.env.example` | URLs ligbox |
| `deploy/vm123-finance-stack/traefik-routes-snippet.yml` | Hosts ligbox |
| `deploy/vm123-finance-stack/install-openpanel.sh` | Domínio default ligbox |
| `deploy/vm123-finance-stack/README.md` | URLs + DNS |
| VM123 `/opt/vm123-finance-stack/.env` | Aplicado em produção |
---
## Próximos passos
1. Wizards FOSSBilling + Odoo na VM123
2. `install-openpanel.sh` (bare metal)
3. `setup-foss-openpanel-module.sh`
4. DNS: `financeiro.ligbox.com.br` + `openpanel.ligbox.com.br`
5. Traefik CT114 — merge `traefik-routes-snippet.yml`
6. DNAT SSH `:2523` pfSense → VM123:22
7. Desk Spec 023 — links financeiro
---
## Canais de cópia
```
/opt/ligbox-ops-platform/specs/024-openpanel-fossbilling/
/opt/ligbox-ops-platform/docs/anais-referencia/
/opt/ligbox-ops-platform/LAPTOP/
/root/obsidian-infra/ligbox-ops-platform/
```

View file

@ -0,0 +1,104 @@
# Índice — Anais de Referência (Ligbox Ops Platform)
**Atualizado:** 2026-06-16
**Responsável:** Roger / Cursor Agent
**VM122:** `10.10.10.122` · SSH WAN `:2522`
---
## Formato
| Tipo | Extensão | Conteúdo |
|------|----------|----------|
| **Aspectos** | `*_ASPECTOS.md` | Decisões, arquitectura, ficheiros, comandos, pendências |
| **Chat bruto** | `*.txt` | Transcript legível (user + assistant + ferramentas) |
| **Chat original** | `*.jsonl` | Transcript Cursor integral |
---
## Entradas
### 2026-06-10 — Spec 013 Migração de E-mail entre Servidores
| Ficheiro | Descrição |
|----------|-----------|
| `specs/013-email-server-migration/spec.md` | Spec completa — gate DNS, fases, API |
| `specs/013-email-server-migration/research.md` | Ferramentas: imapsync, readpst, imap-upload, TGZ |
| `specs/013-email-server-migration/plan.md` | Módulo técnico API + worker |
| `specs/013-email-server-migration/infrastructure.md` | VM/recursos — **futuro, não hoje** |
| `20260610_SPEC_013_EMAIL_MIGRATION.md` | Cópia spec nos anais |
**Regra:** migrar e validar **antes** de virar MX/DNS.
---
---
### 2026-06-16 — Serviços MOSP · Orquestração · Purge SSE/Jobs
| Ficheiro | Descrição |
|----------|-----------|
| `20260616_SERVICOS_ORQUESTRACAO_PURGE_ASPECTOS.md` | Aspectos completos da sessão |
| `chat-bruto/CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.txt` | Chat bruto legível |
| `chat-bruto/CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.jsonl` | JSONL original |
| `specs/017-vm112-domain-orchestration/spec.md` | Purge domínio VM112 |
| `specs/018-service-orchestration/spec.md` | MOSP / Pizza as a Service |
**Transcript:** `ad3c7400-04ce-47bf-8995-2861d54a831b`
**Temas:**
- Página **Serviços** (tenants de oferta, não na Hero VM112)
- Spec 018 MOSP + modelo comercial
- Purge drawer timeline + SSE + **jobs async** (fix 504/Failed to fetch)
- Purges teste dratcoin/eplacebets
- **Pendente VM112 Fase 3** — passos purge em tempo real
---
### 2026-06-10 — Overview + DNS Cloudflare + UI Desk
| Ficheiro | Descrição |
|----------|-----------|
| `20260610_OVERVIEW_DNS_UI_ASPECTOS.md` | Aspectos completos da sessão |
| `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.txt` | Chat bruto legível |
| `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_OVERVIEW_DNS_UI_20260610.jsonl` | JSONL original |
**Transcript:** `161d3d86-8ce8-4a2d-86f7-424b69111cb3`
**Temas:**
- Menu lateral SVG (referência `menu lateral__dashboard.png`)
- Overview clássico — cards por tenant, modal domínio
- Overview Home estilo Cloudflare (menu novo, original preservado)
- API DNS Cloudflare + card na linha Security/Performance/Activity
- Fix exibição DNS (fetch independente do scorecard)
- Deploy Docker rebuild frontend/api
---
## Entradas anteriores (chat bruto)
Ver `INDICE_MODELAGEM_BRUTA.txt` em `/root/ligbox-ops-platform-chat-bruto/`:
- `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_20260604` — visão inicial, arquitectura
- `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_VM122_SPEC_20260608` — Spec Kit, webhooks, Wazuh
---
## Canais espelhados
```
/opt/ligbox-ops-platform/docs/anais-referencia/
/opt/ligbox-ops-platform/chat-bruto/
/root/ligbox-ops-platform-chat-bruto/
/root/ligbox-ops-platform-chat-bruto/anais-referencia/
/opt/ligbox-ops-platform/LAPTOP/
```
---
## Regenerar
```bash
python3 /opt/ligbox-ops-platform/scripts/export-chat-bruto-standalone.py \
<caminho.jsonl> CHAT_BRUTO_<NOME>_<YYYYMMDD> <transcript-uuid>
```

View file

@ -0,0 +1,41 @@
# README — Copiar para LAPTOP / Obsidian (2026-06-16)
**Sessão:** Serviços MOSP · Orquestração VM122 · Purge SSE/Jobs
**Roger**
## Ficheiros desta sessão
| Ficheiro | Descrição |
|----------|-----------|
| `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.txt` | Chat integral legível |
| `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.jsonl` | Transcript Cursor original |
| `20260616_SERVICOS_ORQUESTRACAO_PURGE_ASPECTOS.md` | Decisões, APIs, fixes, próximos passos VM112 |
## Onde estão (VM122)
```
/opt/ligbox-ops-platform/chat-bruto/
/opt/ligbox-ops-platform/docs/anais-referencia/
/opt/ligbox-ops-platform/LAPTOP/
/root/ligbox-ops-platform-chat-bruto/
/root/obsidian-infra/ligbox-ops-platform/
```
## Transcript Cursor
`ad3c7400-04ce-47bf-8995-2861d54a831b`
## Continuar na VM112
1. Ler `20260616_*_ASPECTOS.md` secção 9 (Fase 3 purge passo a passo)
2. Path: `/opt/ligbox-wizard/backend/app/services/domain_orchestration.py`
3. Specs: `017`, `018` em `/opt/ligbox-ops-platform/specs/`
## Regenerar chat bruto
```bash
python3 /opt/ligbox-ops-platform/scripts/export-chat-bruto-standalone.py \
/root/.cursor/projects/1781626937265/agent-transcripts/ad3c7400-04ce-47bf-8995-2861d54a831b/ad3c7400-04ce-47bf-8995-2861d54a831b.jsonl \
CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616 \
ad3c7400-04ce-47bf-8995-2861d54a831b
```

View file

@ -0,0 +1,93 @@
# Spec 024 — Card cliente → FOSS → OpenPanel (provisionamento)
**Roger · 2026-06-17**
## O teu raciocínio está correto
1. **Card do cliente (Desk / portal)** — recolhe dados mínimos do comprador.
2. **FOSSBilling** — cria cliente + pedido + activa produto hosting.
3. **OpenPanel** — recebe API call do FOSS (`createAccount`) e cria user hosting.
4. **pfSense****não** cria conta; só encaminha tráfego WAN → Traefik → VM123.
```
Internet → 95.216.14.146 (pfSense WAN)
→ NAT :80/:443 → 10.10.10.114 (Traefik)
→ financeiro.ligbox.com.br/foss|/odoo → 10.10.10.123
→ openpanel.ligbox.com.br → 10.10.10.123:2083
```
**NAT pfSense já existente (não precisa duplicar):**
| Regra | WAN | Destino |
|-------|-----|---------|
| Traefik HTTP | 80 | 10.10.10.114 |
| Traefik HTTPS | 443 | 10.10.10.114 |
Novos hostnames só precisam de **DNS Cloudflare** → mesmo IP público.
---
## Campos obrigatórios no card (→ FOSS → OpenPanel)
| Campo no card | Vai para FOSSBilling | Vai para OpenPanel API | Notas |
|---------------|----------------------|------------------------|-------|
| **email** | Cliente `email` | `email` | Login/recuperação |
| **nome / empresa** | Cliente `first_name` / company | — | Facturação |
| **domínio** | Opcional no produto | gera `username` (7 chars + dígito) | ex: `cliente1.com` → user `cliente1x` |
| **senha painel** | Order / hosting password | `password` | Senha OpenPanel user |
| **plano** | Product / `plan_name` | `plan_name` | **Deve coincidir** com plano OpenPanel |
| **CPF/CNPJ** | Cliente custom field | — | Fiscal (Odoo fase 2) |
| **telefone** | Cliente `phone` | — | Suporte |
### Plano OpenPanel criado (VM123)
| name | id | Uso |
|------|-----|-----|
| `ligbox-site-cms` | 3 | Site/CMS Spec 018 |
| `Standard plan` | 1 | Testes |
| `Developer Plus` | 2 | Maior |
**FOSS product** deve usar `plan_name` = `ligbox-site-cms` (exacto).
---
## Config FOSSBilling → Server OpenPanel
Admin FOSS → **System → Hosting plans → New server**
| Campo | Valor |
|-------|-------|
| Manager | OpenPanel |
| Hostname | `10.10.10.123` |
| Port | `2087` |
| Secure | Yes (HTTPS) |
| Username | `ligboxadmin` |
| Password | `LbOpen805353` |
Test connection → depois associar produto hosting ao server + plano `ligbox-site-cms`.
---
## Fluxo automático (pedido pago)
```
Card cliente (email, domínio, plano, senha)
→ FOSSBilling: create client + order
→ FOSSBilling: activate hosting
→ OpenPanel.php: POST /api/users
{ email, username, password, plan_name }
→ OpenPanel: conta hosting criada
→ Email cliente com URL openpanel.ligbox.com.br
```
---
## O que o Desk precisa (Spec 023 fase 2)
No card **Serviços / Site CMS**:
- `client_email` *
- `client_name` *
- `domain` * (para username OpenPanel)
- `hosting_plan` * (dropdown: ligbox-site-cms)
- `panel_password` * (ou gerar)
- `foss_client_id` (após sync)
- `openpanel_username` (read-only após provision)

View file

@ -0,0 +1,246 @@
# Spec 024 — FOSSBilling + OpenPanel + Odoo 16 (VM123)
**Criado:** 2026-06-17
**Solicitado por:** Roger
**Status:** Implementação — pacote deploy pronto
**Prioridade:** P1
**Decisão:** **FOSSBilling** + **OpenPanel** + **Odoo V16** · gateway pagamento fase futura
**Relacionado:** Spec 023 (Desk 💳), Spec 018 (Serviços)
---
## Resumo
| Componente | Onde | Como |
|------------|------|------|
| **FOSSBilling** | VM123 Docker | Billing, clientes, pedidos, módulo OpenPanel |
| **Odoo 16** | VM123 Docker | ERP / fiscal (fase contabilidade) |
| **OpenPanel** | VM123 **bare metal** | Hosting Site/CMS |
| **Desk** | VM122 | Ops — wizard, tickets, links financeiro |
| **Gateway** | Fase 2 | ASAAS/Iugu no FOSSBilling |
---
## VM123 — hardware
| Recurso | Valor |
|---------|--------|
| VMID Proxmox | **123** |
| vCPU | **2** |
| RAM | **4 GB** + swap 2 GB |
| Disco | **25 GB** |
| IP LAN | **10.10.10.123** |
| SSH WAN | **:2523** |
| Hostname | `vm123-finance` |
Utilizadores: **root**, **admin**, **mini** — senha **805353**
**fail2ban** activo · **Wazuh agent** → VM104
---
## Credenciais dos serviços (VM123)
Ficheiro: `deploy/vm123-finance-stack/CREDENCIAIS_SERVICOS_VM123.txt`
| Serviço | URL interna | Login | Senha |
|---------|-------------|-------|-------|
| **FOSSBilling Admin** | `http://10.10.10.123:8092/admin` | `admin@ligbox.com.br` | `LbFossAdmin805353` |
| **FOSSBilling Cliente** | `http://10.10.10.123:8092/login` | ver clientes | — |
| **Odoo 16** | `http://10.10.10.123:8069` | `admin@ligbox.com.br` | `LbOdooAdmin805353` |
| **OpenPanel** | `https://10.10.10.123:2087` | `ligboxadmin` | `LbOpen805353` |
URLs públicas (após Traefik/DNS):
| Serviço | URL |
|---------|-----|
| FOSSBilling Admin | `https://financeiro.ligbox.com.br/admin` |
| FOSSBilling Cliente / Signup | `https://financeiro.ligbox.com.br/login` · `/signup` |
| Odoo | `https://financeiro.ligbox.com.br/odoo/web/login?db=ligbox` |
| OpenPanel | `https://openpanel.ligbox.com.br` |
| OpenAdmin | `https://openpanel.ligbox.com.br:2087` |
> **Não usar** `/foss` — FOSSBilling está na **raiz** do domínio `financeiro.ligbox.com.br`.
**Bases de dados:** ver `.env``FOSS_MARIADB_PASSWORD`, `ODOO_DB_PASSWORD`
**Odoo DB:** `ligbox` · master pwd gestor: `admin`
**FOSS ↔ OpenPanel:** módulo `OpenPanel.php` instalado · API `:2087` activa
---
## URLs (Traefik CT114)
| URL | Backend |
|-----|---------|
| `financeiro.ligbox.com.br` (exceto `/odoo`) | VM123:8092 FOSSBilling |
| `financeiro.ligbox.com.br/odoo` | VM123:8069 Odoo 16 |
| `openpanel.ligbox.com.br` | VM123:2083 OpenPanel |
FOSSBilling na **raiz** do domínio; Odoo em **subpath** `/odoo`; OpenPanel em **subdomínio** dedicado.
---
## FOSSBilling — Antispam (signup)
**Problema conhecido (2026-06-17):** o campo honeypot padrão `bio` pode ser preenchido pelo **autocomplete do browser**. O FOSSBilling bloqueia com `Registration failed.` e a UI fica no spinner sem mensagem clara.
**Correção aplicada:**
| Item | Valor |
|------|-------|
| Admin → System → Antispam | Honeypot **activo** |
| Nome do campo honeypot | `lb_hp_x9k2` (não usar `bio`) |
| Template signup | Campo oculto (`position:absolute`, `aria-hidden`, `autocomplete=new-password`) |
| Script reapply | `deploy/vm123-finance-stack/setup-foss-antispam.sh` |
| Patch template | `deploy/vm123-finance-stack/patches/mod_page_signup.html.twig` |
**Reaplicar após rebuild do container FOSS:**
```bash
ssh root@10.10.10.123
bash /opt/ligbox-ops-platform/deploy/vm123-finance-stack/setup-foss-antispam.sh
```
**Admin manual (se script falhar):** `https://financeiro.ligbox.com.br/admin`**System****Antispam** → Honeypot field = `lb_hp_x9k2`.
**Diagnóstico:** log `data/log/php_error.log` no container — mensagem `honeypot field was not empty`.
**Dois logins distintos:**
| Área | URL | Quem |
|------|-----|------|
| Staff/Admin | `/admin` | operadores Ligbox |
| Cliente | `/login` ou `/signup` | clientes finais |
---
## Arquitectura
```
Traefik CT114
┌─────────────────┼─────────────────┐
▼ ▼ ▼
financeiro/foss financeiro/odoo openpanel.ligbox.com.br
│ │ │
└────────┬────────┴────────┬────────┘
▼ │
VM123 10.10.10.123 │
┌────────────────────────────┴───┐
│ Docker: FOSSBilling + Odoo │
│ Host: OpenPanel Enterprise │
└──────────────┬─────────────────┘
│ OpenAdmin API :2087
FOSSBilling Server Manager
(criar/suspender contas hosting)
Desk VM122 ──webhook/link──► FOSSBilling / tickets
Wizard VM112 ──company.validated──► Desk
```
---
## Deploy
Pacote: `deploy/vm123-finance-stack/`
| Ficheiro | Função |
|----------|--------|
| `proxmox-create-vm123.sh` | Criar VM no PVE |
| `bootstrap-vm123.sh` | users, swap, docker, fail2ban |
| `docker-compose.yml` | FOSS + Odoo |
| `install-openpanel.sh` | OpenPanel bare metal |
| `setup-foss-openpanel-module.sh` | Módulo GitHub OpenPanel.php |
| `traefik-routes-snippet.yml` | Rotas CT114 |
| `README.md` | Passo a passo |
---
## Integração FOSS ↔ OpenPanel
Repo: [stefanpejcic/FOSSBilling-OpenPanel](https://github.com/stefanpejcic/FOSSBilling-OpenPanel)
- Create / suspend / unsuspend / cancel / change package ✅
- FOSSBilling Admin → **System → Hosting Plans and Servers****New Server** → Manager OpenPanel, port **2087**
### Onde clicar no FOSS Admin (não é em Settings)
| Passo | Menu / URL |
|-------|------------|
| 1 | **System** (barra superior) → **Hosting Plans and Servers** |
| 2 | Ou directo: `https://financeiro.ligbox.com.br/admin/servicehosting` |
| 3 | Aba **Hosting Servers** → botão azul **New Server** |
| 4 | Manager: **OpenPanel** · Host: `10.10.10.123` · Port: `2087` · SSL: Yes · User: `ligboxadmin` · Pass: `LbOpen805353` |
| 5 | Aba **Hosting Plans****New Plan** → plano `ligbox-site-cms` (espelhar OpenPanel) |
### Estado configurado (2026-06-17 — API)
| Item | ID / Nome | Notas |
|------|-----------|-------|
| Servidor | `VM123 OpenPanel` (id 1) | manager `openpanel`, host `10.10.10.123:2087`, user `ligboxadmin` |
| Plano FOSS | `ligbox-site-cms` (id 1) | = plano OpenPanel id 3 |
| Produto | `Ligbox Site CMS` (id 2) | slug `ligbox-site-cms-hosting`, preço free, domínio próprio |
| Test connection | ✅ OK (bridge Community) | porta **18087** HTTP — ver abaixo |
### OpenPanel Community — bridge API (sem Enterprise)
A API Enterprise (`:2087/api/`) **não existe** na Community. Solução VM123:
| Componente | Detalhe |
|------------|---------|
| Bridge | `openpanel-foss-bridge.service``http://10.10.10.123:18087` |
| Backend | `opencli user-add/suspend/delete` |
| FOSS servidor | Host `10.10.10.123` · Port **18087** · SSL **No** |
| Instalar | `bash install-openpanel-community-bridge.sh` |
| CSF | allow `172.19.0.0/16` → porta 18087 (Docker FOSS) |
Upgrade futuro: licença [OpenPanel Enterprise](https://my.openpanel.com/index.php?rp=/store/openpanel/enterprise-license) → FOSS volta a `:2087` SSL.
### Card cliente → conta (Desk Spec 023)
| Fase | O quê | Quando |
|------|-------|--------|
| **A** ✅ | FOSS + OpenPanel + bridge + produto `Ligbox Site CMS` | Feito 2026-06-17 |
| **B** | Teste pedido manual FOSS → conta OpenPanel | **Agora** (podes encomendar no `/order`) |
| **C** | Desk card campos (`email`, `domínio`, `plano`, `senha painel`) | Spec 023 fase 2 — **próximo sprint** |
| **D** | Webhook Desk → API FOSS `client/create` + `order/create` | Após fase C |
Campos card: ver `PROVISIONING_CLIENT_CARD.md`.
> **Settings** (grelha Activity, Anti-Spam, Client…) é configuração de módulos — **não** é onde se criam servidores.
---
## Odoo 16
- Imagem `odoo:16.0` + `postgres:15-alpine`
- Uso interno Ligbox (parceiros, NF futura)
- API existente Roger (`813f08e7…`) — configurar após 1.º login
- **Não** expor dados sensíveis ao cliente final
---
## Critérios de aceite
- [x] VM123 no ar com IP 10.10.10.123
- [x] `docker compose up -d` — FOSS + Odoo healthy
- [x] OpenPanel instalado — `openpanel.ligbox.com.br` (OpenAdmin :2087)
- [ ] FOSSBilling → teste order → conta OpenPanel
- [ ] Traefik — `/foss` e `/odoo` e openpanel HTTPS
- [x] fail2ban + swap
- [ ] Wazuh agent
- [ ] Desk — link financeiro (Spec 023 fase 1b)
---
## Riscos (4 GB RAM)
Piloto apenas — monitorizar RAM. Se apertar: subir VM para 8 GB ou Odoo noutra VM depois.
---
## Fora de escopo v1
- Gateway ASAAS/Iugu
- Hub custom financeiro.ligbox.com.br
- Paymenter (decisão: FOSSBilling)

View file

@ -0,0 +1,35 @@
# Spec 024 — Tasks
## VM123 Proxmox
- [x] Executar `proxmox-create-vm123.sh` no host PVE
- [x] VM123 online — IP 10.10.10.123/24
- [ ] DNAT SSH WAN :2523 → VM123:22 (pfSense)
## Bootstrap
- [x] `bootstrap-vm123.sh` — mini, admin, root (805353)
- [x] fail2ban activo
- [ ] Wazuh agent → 10.10.10.104
- [x] Swap 2 GB
## Docker FOSS + Odoo
- [x] `docker compose up -d` em `/opt/vm123-finance-stack`
- [x] Wizard FOSSBilling (admin@ligbox.com.br)
- [x] Wizard Odoo 16 (base ligbox)
- [x] `setup-foss-openpanel-module.sh`
## OpenPanel
- [x] `install-openpanel.sh` — Community 1.7.60 + API
- [ ] Planos hosting alinhados Spec 018 Site/CMS
- [ ] Teste provisionamento FOSS → OpenPanel
## Traefik + DNS
- [ ] DNS financeiro + openpanel → IP público
- [ ] Merge `traefik-routes-snippet.yml` CT114 (confirmação Roger)
- [ ] Validar HTTPS /foss /odoo / openpanel
## Desk (Spec 023)
- [ ] Links conta cliente → financeiro/foss
- [ ] billing_accounts.external_id FOSS
## Gateway (futuro)
- [ ] Módulo pagamento FOSSBilling

65
README.md Normal file
View file

@ -0,0 +1,65 @@
# Ligbox Ops Platform
Plataforma de operações Ligbox — Spec-Driven Development com [GitHub Spec Kit](https://github.github.io/spec-kit/).
**VM:** 122 (`ligbox-ops`) · `10.10.10.122` · SSH WAN `95.216.14.146:2522`
---
## Início rápido Spec Kit
Abrir este directório no Cursor Agent e usar os skills:
| Comando | Fase |
|---------|------|
| `/speckit-constitution` | Princípios (já definidos) |
| `/speckit-specify` | Requisitos da feature |
| `/speckit-clarify` | Resolver ambiguidades |
| `/speckit-checklist` | Validar spec |
| `/speckit-plan` | Plano técnico |
| `/speckit-tasks` | Tarefas executáveis |
| `/speckit-analyze` | Consistência |
| `/speckit-implement` | Implementar |
**Ordem recomendada (produção):** constitution → specify → clarify → checklist → plan → tasks → analyze → implement
---
## Documentação
| Ficheiro | Conteúdo |
|----------|----------|
| `.specify/memory/constitution.md` | Constitution geral infra Ligbox |
| `docs/network/INTERFACES_PROXMOX.md` | Bridges, vmbr1, iptables, tabela VMs |
| `chat-bruto/INDICE_MODELAGEM_BRUTA.txt` | Índice sessões Cursor (modelagem bruta) |
### CHAT BRUTO (referência futura)
| Ficheiro | Transcript | Conteúdo |
|----------|------------|----------|
| `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_VM122_SPEC_20260608.*` | `dd17614b-6e05-46cd-9bec-5e963f721c72` | VM122, Spec Kit, features 001/002, UI Desk, Traefik, fila specs, visão dashboard |
| `CHAT_BRUTO_LIGBOX_OPS_PLATFORM_20260604.*` | `e41f7d28-c63a-4d6d-a32c-65bfe4d3684f` | Visão inicial plataforma, agentes, SOC, backlog |
Canais: `workspace/` · `obsidian-infra/ligbox-ops-platform/` (VM112) · `LAPTOP/` (scp Windows)
---
## Estado MVP (VM122)
Deploy em `/opt/ligbox-ops-platform/`:
- API `:8080` · Frontend `:8091` · Redis + Worker internos
- fail2ban activo · webhook secret dev (rotacionar em produção)
Próxima feature (após constitution): `004-onboard-funnel-events``009-ops-audit-overview`
## Specs (fila activa)
| # | Feature | Estado | Descrição |
|---|---------|--------|-----------|
| 001 | webhook-vm112-integration | ✅ MVP | account.created → ticket |
| 002 | wazuh-integration | ✅ | Wazuh ingress + UI |
| **004** | **onboard-funnel-events** | 📋 **Spec pronta** | Funil completo + timeline + widget |
| **009** | **ops-audit-overview** | 📋 **Spec pronta** | Overview Cloudflare-like + 8 checks |
| **019** | **ops-console-active-operations** | 📋 **Spec pronta** | Console UI Wazuh-like **VM123 Docker**; chamado único |
| 003 | desk-auth-rbac | backlog | Auth API pública |

View file

@ -0,0 +1,41 @@
# README — Copiar para LAPTOP / Obsidian (2026-06-16)
**Sessão:** Serviços MOSP · Orquestração VM122 · Purge SSE/Jobs
**Roger**
## Ficheiros desta sessão
| Ficheiro | Descrição |
|----------|-----------|
| `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.txt` | Chat integral legível |
| `CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616.jsonl` | Transcript Cursor original |
| `20260616_SERVICOS_ORQUESTRACAO_PURGE_ASPECTOS.md` | Decisões, APIs, fixes, próximos passos VM112 |
## Onde estão (VM122)
```
/opt/ligbox-ops-platform/chat-bruto/
/opt/ligbox-ops-platform/docs/anais-referencia/
/opt/ligbox-ops-platform/LAPTOP/
/root/ligbox-ops-platform-chat-bruto/
/root/obsidian-infra/ligbox-ops-platform/
```
## Transcript Cursor
`ad3c7400-04ce-47bf-8995-2861d54a831b`
## Continuar na VM112
1. Ler `20260616_*_ASPECTOS.md` secção 9 (Fase 3 purge passo a passo)
2. Path: `/opt/ligbox-wizard/backend/app/services/domain_orchestration.py`
3. Specs: `017`, `018` em `/opt/ligbox-ops-platform/specs/`
## Regenerar chat bruto
```bash
python3 /opt/ligbox-ops-platform/scripts/export-chat-bruto-standalone.py \
/root/.cursor/projects/1781626937265/agent-transcripts/ad3c7400-04ce-47bf-8995-2861d54a831b/ad3c7400-04ce-47bf-8995-2861d54a831b.jsonl \
CHAT_BRUTO_LIGBOX_OPS_SERVICOS_ORQUESTRACAO_PURGE_20260616 \
ad3c7400-04ce-47bf-8995-2861d54a831b
```

292
activate.html Normal file
View file

@ -0,0 +1,292 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Ativar conta — Ligbox Ops</title>
<link rel="stylesheet" href="/assets/styles.css"/>
</head>
<body class="login-page activate-page">
<div class="activate-card">
<header class="activate-header">
<div>
<h1>Ativar conta</h1>
<p class="activate-sub">Complete <strong>2 de 3</strong> fatores — escolha os que preferir</p>
</div>
<p id="activate-info" class="activate-account">Carregando…</p>
</header>
<div id="factor-progress" class="factor-progress" hidden>
<div class="factor-progress-bar"><span id="factor-progress-fill"></span></div>
<span id="factor-progress-text">0/2 fatores</span>
</div>
<form id="activate-form" class="activate-form" hidden>
<div class="factor-grid">
<section class="factor-tile" id="factor-email" data-factor="email">
<div class="factor-tile-head">
<span class="factor-num">1</span>
<div>
<strong>E-mail</strong>
<p class="factor-desc">Código de 6 dígitos</p>
</div>
<span class="factor-check" id="chk-email" hidden aria-label="validado"></span>
</div>
<button type="button" class="btn btn-ghost btn-sm btn-block" id="btn-email-otp">Enviar código</button>
<label class="factor-label">
Código
<input type="text" name="email_otp" inputmode="numeric" maxlength="6" placeholder="000000" autocomplete="one-time-code"/>
</label>
</section>
<section class="factor-tile" id="factor-phone" data-factor="phone">
<div class="factor-tile-head">
<span class="factor-num">2</span>
<div>
<strong>Telefone</strong>
<p class="factor-desc">Por e-mail (SMS em breve)</p>
</div>
<span class="factor-check" id="chk-phone" hidden aria-label="validado"></span>
</div>
<label class="factor-label">
Número
<input type="tel" id="act-phone" name="phone" placeholder="+5511999999999"/>
</label>
<button type="button" class="btn btn-ghost btn-sm btn-block" id="btn-phone-otp">Enviar código</button>
<label class="factor-label">
Código
<input type="text" name="phone_otp" inputmode="numeric" maxlength="6" placeholder="000000"/>
</label>
</section>
<section class="factor-tile factor-tile-totp" id="factor-totp" data-factor="totp">
<div class="factor-tile-head">
<span class="factor-num">3</span>
<div>
<strong>App autenticador</strong>
<p class="factor-desc">Google Authenticator / Authy</p>
</div>
<span class="factor-check" id="chk-totp" hidden aria-label="validado"></span>
</div>
<div class="qr-panel">
<p class="qr-label">Escaneie o QR code</p>
<div id="qr-wrap" class="qr-wrap">
<p id="qr-loading" class="qr-placeholder">Gerando QR…</p>
</div>
<p id="qr-error" class="qr-error" hidden>Não foi possível gerar o QR. Recarregue a página.</p>
</div>
<div id="ntfy-box" class="ntfy-box" hidden>
<p class="ntfy-title">Push opcional (ntfy)</p>
<a id="ntfy-link" class="ntfy-link" href="#" target="_blank" rel="noopener">Inscrever no tópico</a>
</div>
<label class="factor-label">
Código do app
<input type="text" name="totp_code" inputmode="numeric" maxlength="6" placeholder="000000"/>
</label>
</section>
</div>
<p id="activate-error" class="login-error activate-feedback" hidden></p>
<p id="activate-success" class="login-notice activate-feedback" hidden></p>
<button type="submit" class="btn btn-primary btn-block activate-submit">Ativar conta</button>
</form>
<p class="login-hint activate-footer">
<a href="/login.html">← Voltar ao login</a>
</p>
</div>
<script src="/assets/qrcode.min.js?v=20260610k"></script>
<script src="/assets/auth.js?v=20260610k"></script>
<script>
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
const info = document.getElementById('activate-info');
const progress = document.getElementById('factor-progress');
const progressFill = document.getElementById('factor-progress-fill');
const progressText = document.getElementById('factor-progress-text');
const form = document.getElementById('activate-form');
const err = document.getElementById('activate-error');
const ok = document.getElementById('activate-success');
function setTileVerified(name, on) {
const tile = document.querySelector(`[data-factor="${name}"]`);
tile?.classList.toggle('factor-tile-done', on);
const chk = document.getElementById(`chk-${name}`);
if (chk) chk.hidden = !on;
}
function updateProgress(factors) {
if (!factors) return;
const n = factors.verified_count || 0;
const req = factors.required || 2;
const pct = Math.min(100, Math.round((n / req) * 100));
progressFill.style.width = `${pct}%`;
progressText.textContent = n >= req
? `${n}/${req} fatores — pronto para ativar`
: `${n}/${req} fatores — faltam ${req - n}`;
progress.hidden = false;
setTileVerified('email', !!factors.email);
setTileVerified('phone', !!factors.phone);
setTileVerified('totp', !!factors.totp);
}
function renderQr(uri) {
const wrap = document.getElementById('qr-wrap');
const loading = document.getElementById('qr-loading');
const qrErr = document.getElementById('qr-error');
if (!uri) {
loading.textContent = 'QR indisponível';
return;
}
if (typeof QRCode === 'undefined') {
loading.hidden = true;
qrErr.textContent = 'Biblioteca QR não carregou. Recarregue a página (Ctrl+Shift+R).';
qrErr.hidden = false;
return;
}
loading.textContent = 'Gerando QR…';
QRCode.toCanvas(uri, { width: 140, margin: 1 }, (e, canvas) => {
loading.hidden = true;
if (e || !canvas) {
qrErr.textContent = 'Erro ao gerar QR. Recarregue a página.';
qrErr.hidden = false;
console.error('QRCode:', e);
return;
}
wrap.innerHTML = '';
wrap.appendChild(canvas);
});
}
function setupNtfy(topic, subscribeUrl) {
if (!topic) return;
const box = document.getElementById('ntfy-box');
const link = document.getElementById('ntfy-link');
const url = subscribeUrl || `https://ntfy.sh/${topic}`;
link.href = url;
link.textContent = topic;
box.hidden = false;
}
async function refreshStatus() {
const r = await fetchWithTimeout(`/api/v1/auth/activate?token=${encodeURIComponent(token)}`);
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || 'Token inválido');
updateProgress(d.factors);
return d;
}
if (!token) {
info.textContent = 'Link inválido — falta token de ativação.';
} else {
refreshStatus()
.then((d) => {
info.innerHTML = `<strong>${d.email}</strong> · ${d.role || 'técnico'}`;
form.hidden = false;
if (d.otpauth_uri) renderQr(d.otpauth_uri);
setupNtfy(d.ntfy_topic, d.ntfy_subscribe_url);
})
.catch((e) => { info.textContent = e.message; });
}
document.getElementById('btn-email-otp')?.addEventListener('click', async () => {
err.hidden = true;
try {
const r = await fetchWithTimeout(`/api/v1/auth/activate/send-email-otp?token=${encodeURIComponent(token)}`, { method: 'POST' });
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || `Falha ao enviar (${r.status})`);
ok.textContent = d.message;
ok.hidden = false;
} catch (e) {
err.textContent = e.message;
err.hidden = false;
}
});
document.getElementById('btn-phone-otp')?.addEventListener('click', async () => {
err.hidden = true;
ok.hidden = true;
const phone = document.getElementById('act-phone')?.value?.trim();
if (!phone) {
err.textContent = 'Informe o telefone primeiro';
err.hidden = false;
return;
}
try {
const r = await fetchWithTimeout('/api/v1/auth/activate/send-phone-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, phone }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || `Falha ao enviar (${r.status})`);
ok.textContent = d.message;
ok.hidden = false;
} catch (e) {
err.textContent = e.message;
err.hidden = false;
}
});
form?.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
ok.hidden = true;
const btn = form.querySelector('button[type="submit"]');
const body = { token };
const emailOtp = form.email_otp.value.trim();
const phoneOtp = form.phone_otp.value.trim();
const totpCode = form.totp_code.value.trim();
if (emailOtp) body.email_otp = emailOtp;
if (phoneOtp) body.phone_otp = phoneOtp;
if (totpCode) body.totp_code = totpCode;
const filled = [emailOtp, phoneOtp, totpCode].filter(Boolean).length;
if (filled < 2) {
err.textContent = 'Preencha códigos de pelo menos 2 fatores diferentes';
err.hidden = false;
return;
}
btn.disabled = true;
try {
const r = await fetchWithTimeout('/api/v1/auth/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
await refreshStatus().catch(() => {});
throw new Error(d.detail || 'Ativação falhou');
}
if (d.backup_codes?.length) {
form.hidden = true;
progress.hidden = true;
const panel = document.createElement('div');
panel.className = 'backup-codes-panel';
panel.innerHTML = `
<p class="login-hint" style="text-align:left">
<strong>Guarde estes códigos de backup</strong><br/>
Use no login se perder o autenticador. Cada código é de uso único. Também enviamos por e-mail.
</p>
<ul class="backup-codes-list">${d.backup_codes.map((c) => `<li><code>${c}</code></li>`).join('')}</ul>
<button type="button" class="btn btn-primary btn-block" id="activate-go-login">Ir para o login</button>`;
document.querySelector('.activate-card')?.appendChild(panel);
panel.querySelector('#activate-go-login')?.addEventListener('click', () => {
window.location.href = '/login.html';
});
return;
}
ok.textContent = (d.message || 'Conta ativa') + ' Redirecionando…';
ok.hidden = false;
setTimeout(() => { window.location.href = '/login.html'; }, 2000);
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>

9
api/Dockerfile Normal file
View file

@ -0,0 +1,9 @@
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends dnsutils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
ENV PYTHONPATH=/app
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

0
api/app/__init__.py Normal file
View file

View file

@ -0,0 +1 @@
"""Agentic Ops — Spec 029."""

95
api/app/agents/checks.py Normal file
View file

@ -0,0 +1,95 @@
"""T0/T1 checks — Spec 029."""
from __future__ import annotations
import os, sqlite3, time
import httpx
DESK = os.getenv("DESK_PUBLIC_URL", "https://desk.ligbox.com.br")
VM112 = os.getenv("VM112_API_URL", "http://10.10.10.112:8090")
WIZARD = os.getenv("WIZARD_ONBOARD_URL", "https://onboard.ligbox.com.br/onboard")
PFS_URL = os.getenv("PFSENSE_API_URL", "https://firewall.itecnologys.com/api/v2/status/system")
PFS_USER = os.getenv("PFSENSE_API_USER", "api_cursor")
PFS_PASS = os.getenv("PFSENSE_API_PASSWORD", "805353")
PVE = os.getenv("PVE_API_URL", "https://10.10.10.2:8006/api2/json")
PVE_USER = os.getenv("PVE_USER", "root@pam")
PVE_PASS = os.getenv("PVE_PASSWORD", "@betinplace")
PVE_NODE = os.getenv("PVE_NODE", "big1")
VMIDS = [int(x) for x in os.getenv("AGENTIC_CRITICAL_VMIDS", "112,122,123,104").split(",") if x.strip()]
OLLAMA = os.getenv("OLLAMA_BASE_URL", "http://10.10.10.123:11434").rstrip("/")
def _http(url, *, auth=None, max_ms=2500):
t0 = time.perf_counter()
try:
with httpx.Client(timeout=15, verify=False, follow_redirects=True) as c:
r = c.get(url, auth=auth)
ms = int((time.perf_counter()-t0)*1000)
return {"ok": r.status_code==200 and ms<=max_ms, "status_code": r.status_code, "latency_ms": ms, "url": url}
except Exception as e:
return {"ok": False, "error": str(e), "url": url}
def check_desk_api_health():
r = _http(f"{DESK}/api/health")
return [] if r["ok"] else [{"severity":"high","category":"api","title":"Desk API health falhou","detail_md":str(r),"evidence":r,"human_action":"docker-compose logs api VM122"}]
def check_vm112_health():
out = []
r1 = _http(f"{VM112}/api/onboarding/health")
if not r1["ok"]: out.append({"severity":"high","category":"api","title":"VM112 API down","detail_md":str(r1),"evidence":r1,"human_action":"systemctl ligbox-wizard VM112"})
r2 = _http(WIZARD, max_ms=4000)
if not r2["ok"]: out.append({"severity":"warn","category":"api","title":"Portal /onboard falhou","detail_md":str(r2),"evidence":r2,"human_action":"Traefik + VM112"})
return out
def check_pfsense_api():
r = _http(PFS_URL, auth=(PFS_USER, PFS_PASS), max_ms=4000)
return [] if r["ok"] else [{"severity":"warn","category":"infra","title":"pfSense API falhou","detail_md":str(r),"evidence":r,"human_action":"firewall.itecnologys.com"}]
def check_funnel_stuck(conn, max_stuck=5):
try:
c = conn.execute("SELECT COUNT(*) n FROM tickets WHERE status IN ('open','assisting','escalated') AND (subject LIKE '%onboarding%' OR payload LIKE '%onboarding%') AND datetime(created_at)<datetime('now','-24 hours')").fetchone()["n"]
if c <= max_stuck: return []
return [{"severity":"warn","category":"code","title":f"Funil travado {c} tickets","detail_md":str(c),"evidence":{"count":c},"human_action":"ASM Spec 010"}]
except sqlite3.OperationalError:
return []
def check_integration_gap(ops_api_url, token):
if not token: return []
try:
with httpx.Client(timeout=15) as c:
r = c.get(f"{ops_api_url}/api/v1/integrations/health", headers={"X-Ops-Internal-Token": token})
if r.status_code != 200: return []
gap = (r.json().get("vm112_onboard") or {}).get("gap_minutes")
if gap is None or int(gap) <= 15: return []
return [{"severity":"high","category":"infra","title":f"Gap webhook {int(gap)}min","detail_md":"VM112 sem eventos","evidence":{"gap":gap},"human_action":"Webhooks VM112→122"}]
except Exception:
return []
def check_proxmox_cluster():
try:
with httpx.Client(timeout=15, verify=False) as c:
t = c.post(f"{PVE}/access/ticket", data={"username": PVE_USER, "password": PVE_PASS})
if t.status_code != 200:
return [{"severity":"warn","category":"infra","title":"Proxmox auth falhou","detail_md":str(t.status_code),"evidence":{},"human_action":"PVE 10.10.10.2:8006"}]
tok = t.json()["data"]["ticket"]
bad = []
with httpx.Client(timeout=15, verify=False) as c:
for vmid in VMIDS:
r = c.get(f"{PVE}/nodes/{PVE_NODE}/qemu/{vmid}/status/current", headers={"Cookie": f"PVEAuthCookie={tok}"})
st = r.json().get("data", {}).get("status") if r.status_code == 200 else "error"
if st != "running": bad.append({"vmid": vmid, "status": st})
if not bad: return []
return [{"severity":"critical","category":"infra","title":f"VMs paradas {bad}","detail_md":str(bad),"evidence":{"bad":bad},"human_action":"qm start no big1"}]
except Exception as e:
return [{"severity":"info","category":"infra","title":"Proxmox check erro","detail_md":str(e),"evidence":{},"human_action":""}]
def check_ollama_vm123():
r = _http(f"{OLLAMA}/api/tags", max_ms=5000)
return [] if r["ok"] else [{"severity":"high","category":"infra","title":"Ollama VM123 offline","detail_md":str(r),"evidence":r,"human_action":"systemctl start ollama VM123"}]
SCENARIO_RUNNERS = {
"desk.api.health": lambda conn, **kw: check_desk_api_health(),
"wizard.vm112.bundle": lambda conn, **kw: check_vm112_health(),
"pfsense.api.system": lambda conn, **kw: check_pfsense_api(),
"funnel.stuck.onboarding": lambda conn, **kw: check_funnel_stuck(conn),
"integration.webhook.gap": lambda conn, **kw: check_integration_gap(kw.get("ops_api_url",""), kw.get("internal_token","")),
"proxmox.cluster": lambda conn, **kw: check_proxmox_cluster(),
"ollama.vm123.health": lambda conn, **kw: check_ollama_vm123(),
}

View file

@ -0,0 +1,38 @@
"""Ollama VM123 + fallback — Spec 029 T1."""
from __future__ import annotations
import os
import httpx
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://10.10.10.123:11434").rstrip("/")
AGENTIC_LLM_MODEL = os.getenv("AGENTIC_LLM_MODEL", "qwen2.5:7b-instruct")
AGENTIC_LLM_ENABLED = os.getenv("AGENTIC_LLM_ENABLED", "false").lower() in ("1", "true", "yes")
def ollama_available() -> bool:
try:
with httpx.Client(timeout=3.0) as c:
return c.get(f"{OLLAMA_BASE_URL}/api/tags").status_code == 200
except Exception:
return False
def advise_human_action(*, finding_title: str, finding_detail: str, kb_snippets: list[str] | None = None) -> tuple[str, str]:
prompt = (
"Advisor Agentic Ops Ligbox. Português BR, máx 6 frases. O que fazer AGORA?\n"
f"Problema: {finding_title}\nDetalhe: {finding_detail}\nKB: {'---'.join(kb_snippets or [])[:2500] or 'N/A'}"
)
if not AGENTIC_LLM_ENABLED:
return (f"Investigar manualmente: {finding_title}", "t0")
if ollama_available():
try:
with httpx.Client(timeout=90.0) as c:
r = c.post(f"{OLLAMA_BASE_URL}/api/chat", json={
"model": AGENTIC_LLM_MODEL,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
})
if r.status_code == 200:
txt = (r.json().get("message") or {}).get("content", "").strip()
if txt:
return txt, AGENTIC_LLM_MODEL
except Exception:
pass
return (f"Rever logs e specs para: {finding_title}", "t0-fallback")

24
api/app/agents/notify.py Normal file
View file

@ -0,0 +1,24 @@
"""Alerts Advisor — Spec 029."""
from __future__ import annotations
import os, urllib.request
from app import mail_notify
NTFY = os.getenv("DESK_OPS_NTFY_TOPIC", os.getenv("AGENTIC_NTFY_TOPIC", ""))
ROOT = os.getenv("DESK_ROOT_NOTIFY_EMAIL", "admin@ligbox.com.br")
def notify_finding(finding: dict) -> bool:
if finding.get("severity") not in ("high", "critical"):
return False
title = finding.get("title", "Agentic")
body = finding.get("suggested_human_action") or finding.get("detail_md", "")
if NTFY:
try:
req = urllib.request.Request(
f"https://ntfy.sh/{NTFY}", data=f"[Agentic] {title}\n{body}".encode(),
method="POST", headers={"Title": f"Agentic — {title}", "Priority": "high"},
)
urllib.request.urlopen(req, timeout=8)
except Exception:
pass
mail_notify.send_email(ROOT, f"[Ligbox Agentic] {title}", f"{body}\n— VM122")
return True

View file

@ -0,0 +1,20 @@
"""Scenario registry — Spec 029."""
from __future__ import annotations
from pathlib import Path
import yaml
REGISTRY = Path(__file__).resolve().parent / "scenarios" / "registry.yaml"
def load_registry() -> list[dict]:
if REGISTRY.exists():
data = yaml.safe_load(REGISTRY.read_text(encoding="utf-8")) or {}
return list(data.get("scenarios") or [])
return [
{"id": "desk.api.health", "title": "Desk VM122 API", "severity_default": "high"},
{"id": "wizard.vm112.bundle", "title": "VM112 Wizard", "severity_default": "high"},
{"id": "pfsense.api.system", "title": "pfSense API", "severity_default": "warn"},
{"id": "funnel.stuck.onboarding", "title": "Funil travado", "severity_default": "warn"},
{"id": "integration.webhook.gap", "title": "Gap webhook VM112", "severity_default": "high"},
{"id": "proxmox.cluster", "title": "Proxmox VMs críticas", "severity_default": "critical"},
{"id": "ollama.vm123.health", "title": "Ollama VM123", "severity_default": "high"},
]

63
api/app/agents/routes.py Normal file
View file

@ -0,0 +1,63 @@
"""Agentic API — Spec 029."""
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from app import auth
from app.agents import llm_client, runner, store
router = APIRouter(prefix="/api/v1/agents", tags=["agents"])
def _db():
conn = auth.db()
try: yield conn
finally: conn.close()
def _ops_view(user):
if user.role not in ("super_admin","ops_lead","technician","noc","agentic_operator"):
raise HTTPException(403, "insufficient permissions")
@router.get("/health")
def agents_health():
return {"status":"ok","tier":"t1" if llm_client.AGENTIC_LLM_ENABLED else "t0",
"ollama": llm_client.ollama_available(), "ollama_url": llm_client.OLLAMA_BASE_URL,
"model": llm_client.AGENTIC_LLM_MODEL}
@router.get("/scenarios")
def list_scenarios(user=Depends(auth.get_current_user), conn=Depends(_db)):
_ops_view(user); runner.sync_registry(conn); conn.commit()
return {"scenarios": store.list_scenarios(conn)}
@router.get("/findings")
def list_findings(user=Depends(auth.get_current_user), conn=Depends(_db), severity: str|None=None, limit: int=Query(50, ge=1, le=200), open_only: bool=True):
_ops_view(user)
return {"findings": store.list_findings(conn, severity=severity, limit=limit, open_only=open_only)}
@router.post("/findings/{finding_id}/ack")
def ack_finding(finding_id: int, user=Depends(auth.get_current_user), conn=Depends(_db)):
_ops_view(user)
if not conn.execute("SELECT id FROM agent_findings WHERE id=?", (finding_id,)).fetchone():
raise HTTPException(404, "not found")
now = datetime.now(timezone.utc).isoformat()
conn.execute("UPDATE agent_findings SET acknowledged_at=?, acknowledged_by=? WHERE id=?", (now, user.username, finding_id))
store.log_event(conn, event_type="finding.ack", message=f"#{finding_id}", payload={"by": user.username})
conn.commit()
return {"ok": True, "id": finding_id}
@router.get("/action-log")
def action_log(user=Depends(auth.get_current_user), conn=Depends(_db), limit: int=Query(100, ge=1, le=500)):
_ops_view(user)
return {"events": store.list_action_log(conn, limit=limit)}
@router.post("/runs/{scenario_id}")
def trigger_run(scenario_id: str, user=Depends(auth.get_current_user), conn=Depends(_db)):
if user.role not in ("super_admin","ops_lead"): raise HTTPException(403, "insufficient permissions")
r = runner.run_scenario(conn, scenario_id, trigger=f"manual:{user.username}")
conn.commit(); return r
@router.post("/internal/tick")
def internal_tick(user=Depends(auth.require_internal_or_user), conn=Depends(_db)):
kb = runner.index_specs_kb(conn)
result = runner.run_all_enabled(conn, trigger="cron")
store.log_event(conn, event_type="tick.complete", message=f"kb={kb} runs={result['total']}", payload={"kb": kb, **result})
conn.commit()
return {"ok": True, "kb_indexed": kb, **result}

62
api/app/agents/runner.py Normal file
View file

@ -0,0 +1,62 @@
"""Agent orchestration — Spec 029."""
from __future__ import annotations
import os
from pathlib import Path
from app.agents import checks, llm_client, notify, registry, store
SPECS = Path(os.getenv("AGENTIC_SPECS_ROOT", "/opt/ligbox-ops-platform/specs"))
OPS_API = os.getenv("OPS_API_URL", "http://api:8080")
TOKEN = os.getenv("OPS_INTERNAL_TOKEN", "")
def sync_registry(conn):
for sc in registry.load_registry():
store.upsert_scenario(conn, sc)
return len(registry.load_registry())
def index_specs_kb(conn):
if not SPECS.exists(): return 0
n = 0
for p in sorted(SPECS.glob("**/*.md")):
try:
txt = p.read_text(encoding="utf-8", errors="ignore")
if len(txt) > 100:
store.index_kb_file(conn, str(p.relative_to(SPECS)), txt)
n += 1
except OSError:
pass
return n
def run_scenario(conn, scenario_id, *, trigger="cron"):
sc = store.get_scenario(conn, scenario_id)
if not sc: return {"ok": False, "error": "not found"}
fn = checks.SCENARIO_RUNNERS.get(scenario_id)
if not fn: return {"ok": False, "error": "no runner"}
run_id = store.create_run(conn, scenario_id, trigger)
store.log_event(conn, event_type="run.start", message=scenario_id, run_id=run_id)
raw = fn(conn, ops_api_url=OPS_API, internal_token=TOKEN)
fids = []
for f in raw:
kb = store.search_kb(conn, f.get("title", ""))
human = f.get("human_action") or ""
if f.get("severity") in ("high", "critical", "warn"):
advice, model = llm_client.advise_human_action(
finding_title=f.get("title",""), finding_detail=f.get("detail_md",""),
kb_snippets=[k["snippet"] for k in kb],
)
if advice and not human: human = advice
fid = store.add_finding(conn, run_id, severity=f.get("severity", sc.get("severity_default","warn")),
category=f.get("category","api"), title=f.get("title","Finding"), detail_md=f.get("detail_md",""),
evidence=f.get("evidence"), human_action=human, kb_refs=[k["source"] for k in kb])
fids.append(fid)
if f.get("severity") in ("high", "critical"):
notify.notify_finding({**f, "suggested_human_action": human})
store.log_event(conn, event_type="finding.created", message=f.get("title",""), run_id=run_id, payload={"id": fid})
status = "ok" if not raw else "degraded"
store.finish_run(conn, run_id, status=status, summary=f"{len(raw)} finding(s)" if raw else "healthy")
store.log_event(conn, event_type="run.finish", message=status, run_id=run_id)
return {"ok": True, "run_id": run_id, "scenario_id": scenario_id, "status": status, "findings_count": len(raw), "finding_ids": fids}
def run_all_enabled(conn, trigger="cron"):
sync_registry(conn)
return {"runs": [run_scenario(conn, s["id"], trigger=trigger) for s in store.list_scenarios(conn)],
"total": len(store.list_scenarios(conn))}

View file

@ -0,0 +1,23 @@
version: 1
scenarios:
- id: desk.api.health
title: Desk VM122 API
severity_default: high
- id: wizard.vm112.bundle
title: VM112 Wizard
severity_default: high
- id: pfsense.api.system
title: pfSense API
severity_default: warn
- id: funnel.stuck.onboarding
title: Funil travado
severity_default: warn
- id: integration.webhook.gap
title: Gap webhook VM112
severity_default: high
- id: proxmox.cluster
title: Proxmox VMs críticas
severity_default: critical
- id: ollama.vm123.health
title: Ollama VM123
severity_default: high

104
api/app/agents/store.py Normal file
View file

@ -0,0 +1,104 @@
"""Persistence Agentic Ops."""
from __future__ import annotations
import json, sqlite3
from datetime import datetime, timezone
from typing import Any
def _now():
return datetime.now(timezone.utc).isoformat()
def init_agent_schema(conn):
conn.executescript("""
CREATE TABLE IF NOT EXISTS agent_scenarios (
id TEXT PRIMARY KEY, title TEXT NOT NULL, schedule TEXT,
severity_default TEXT NOT NULL DEFAULT 'warn', config_json TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS agent_runs (
id INTEGER PRIMARY KEY, scenario_id TEXT NOT NULL, trigger TEXT NOT NULL DEFAULT 'cron',
status TEXT NOT NULL, summary_text TEXT, llm_model TEXT, metadata_json TEXT,
started_at TEXT NOT NULL, finished_at TEXT);
CREATE TABLE IF NOT EXISTS agent_findings (
id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL, severity TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'api', title TEXT NOT NULL, detail_md TEXT,
evidence_json TEXT, suggested_human_action TEXT, kb_refs_json TEXT,
acknowledged_at TEXT, acknowledged_by TEXT, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS agent_action_log (
id INTEGER PRIMARY KEY, ts TEXT NOT NULL, agent_id TEXT NOT NULL DEFAULT 'sentinel',
run_id INTEGER, event_type TEXT NOT NULL, message TEXT, payload_json TEXT);
CREATE TABLE IF NOT EXISTS agent_kb_chunks (
id INTEGER PRIMARY KEY, source_path TEXT NOT NULL, chunk_text TEXT NOT NULL, indexed_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS idx_agent_runs_scenario ON agent_runs(scenario_id);
""")
def log_event(conn, *, event_type, message, agent_id="sentinel", run_id=None, payload=None):
conn.execute("INSERT INTO agent_action_log (ts,agent_id,run_id,event_type,message,payload_json) VALUES (?,?,?,?,?,?)",
(_now(), agent_id, run_id, event_type, message, json.dumps(payload or {})))
try:
conn.execute("INSERT INTO desk_security_audit (username,event_type,client_ip,payload,created_at) VALUES (?,?,?,?,?)",
("agentic", f"agent.{event_type}", "vm122", json.dumps({"message": message, **(payload or {})}), _now()))
except sqlite3.OperationalError:
pass
def upsert_scenario(conn, scenario):
conn.execute("""INSERT INTO agent_scenarios (id,title,schedule,severity_default,config_json,enabled,updated_at)
VALUES (?,?,?,?,?,1,?) ON CONFLICT(id) DO UPDATE SET title=excluded.title, schedule=excluded.schedule,
severity_default=excluded.severity_default, config_json=excluded.config_json, updated_at=excluded.updated_at""",
(scenario["id"], scenario["title"], scenario.get("schedule","*/5 * * * *"),
scenario.get("severity_default","warn"), json.dumps(scenario), _now()))
def list_scenarios(conn):
out = []
for row in conn.execute("SELECT * FROM agent_scenarios WHERE enabled=1 ORDER BY id"):
item = dict(row)
item["config"] = json.loads(item.pop("config_json") or "{}")
last = conn.execute("SELECT status,started_at FROM agent_runs WHERE scenario_id=? ORDER BY id DESC LIMIT 1", (row["id"],)).fetchone()
item["last_run_status"] = last["status"] if last else None
item["last_run_at"] = last["started_at"] if last else None
out.append(item)
return out
def get_scenario(conn, scenario_id):
row = conn.execute("SELECT * FROM agent_scenarios WHERE id=? AND enabled=1", (scenario_id,)).fetchone()
if not row: return None
item = dict(row); item["config"] = json.loads(item.pop("config_json") or "{}"); return item
def create_run(conn, scenario_id, trigger):
return int(conn.execute("INSERT INTO agent_runs (scenario_id,trigger,status,started_at) VALUES (?,?,?,?)",
(scenario_id, trigger, "running", _now())).lastrowid)
def finish_run(conn, run_id, *, status, summary, llm_model=None, metadata=None):
conn.execute("UPDATE agent_runs SET status=?,summary_text=?,llm_model=?,metadata_json=?,finished_at=? WHERE id=?",
(status, summary, llm_model, json.dumps(metadata or {}), _now(), run_id))
def add_finding(conn, run_id, *, severity, category, title, detail_md="", evidence=None, human_action="", kb_refs=None):
return int(conn.execute("""INSERT INTO agent_findings (run_id,severity,category,title,detail_md,evidence_json,
suggested_human_action,kb_refs_json,created_at) VALUES (?,?,?,?,?,?,?,?,?)""",
(run_id, severity, category, title, detail_md, json.dumps(evidence or {}), human_action,
json.dumps(kb_refs or []), _now())).lastrowid)
def list_findings(conn, *, severity=None, limit=50, open_only=True):
q, params = "SELECT * FROM agent_findings WHERE 1=1", []
if severity: q += " AND severity=?"; params.append(severity)
if open_only: q += " AND acknowledged_at IS NULL"
q += " ORDER BY id DESC LIMIT ?"; params.append(limit)
return [dict(r) for r in conn.execute(q, params)]
def list_action_log(conn, limit=100):
return [dict(r) for r in conn.execute("SELECT * FROM agent_action_log ORDER BY id DESC LIMIT ?", (limit,))]
def index_kb_file(conn, source_path, text):
conn.execute("DELETE FROM agent_kb_chunks WHERE source_path=?", (source_path,))
now = _now()
for i in range(0, len(text), 1200):
conn.execute("INSERT INTO agent_kb_chunks (source_path,chunk_text,indexed_at) VALUES (?,?,?)",
(source_path, text[i:i+1200], now))
def search_kb(conn, query, limit=8):
terms = [t.strip().lower() for t in query.split() if len(t.strip()) > 2]
if not terms: return []
scored = []
for row in conn.execute("SELECT source_path,chunk_text FROM agent_kb_chunks"):
score = sum(1 for t in terms if t in row["chunk_text"].lower())
if score: scored.append((score, {"source": row["source_path"], "snippet": row["chunk_text"][:400]}))
scored.sort(key=lambda x: -x[0])
return [s[1] for s in scored[:limit]]

184
api/app/assist_catalog.py Normal file
View file

@ -0,0 +1,184 @@
"""Catálogo de acções Desk + links externos + ranking técnicos — Spec 010 Fase C/F."""
from __future__ import annotations
import os
import sqlite3
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any
FUNNEL_RANK_BY_STAGE = {
"started": 1,
"domain_validated": 2,
"dns_applied": 3,
"account_created": 4,
"infra_synced": 5,
"completed": 6,
"failed": 99,
}
DESK_ACTIONS = {
"dns.revalidate": {
"label": "Revalidar DNS",
"min_rank": 3,
"method": "GET",
"path": "/onboarding/dns/verify/{domain}",
},
"dns.reapply": {
"label": "Reaplicar DNS Cloudflare",
"min_rank": 3,
"method": "POST",
"path": "/onboarding/dns/cloudflare/apply",
"body": lambda domain, _payload: {"domain": domain},
},
"account.retry_sync": {
"label": "Reverificar infra/conta",
"min_rank": 4,
"method": "GET",
"path": "/onboarding/infrastructure/status/{domain}",
},
"infra.resync": {
"label": "Resync infra (Traefik/cert)",
"min_rank": 5,
"method": "POST",
"path": "/onboarding/infrastructure/provision",
"body": lambda domain, _payload: {"domain": domain},
},
}
ABORT_ACTION = "onboarding.abort"
MARK_STEP_ACTION = "onboarding.mark_step_complete"
PROXMOX_URL = os.getenv("DESK_LINK_PROXMOX", "https://proxmox.itecnologys.com")
TRAEFIK_URL = os.getenv("DESK_LINK_TRAEFIK", "https://traefik.itecnologys.com/dashboard/")
CARBONIO_ADMIN = os.getenv("DESK_LINK_CARBONIO", "https://mail.ibytera.com/admin")
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def funnel_rank(stage: str | None) -> int:
return FUNNEL_RANK_BY_STAGE.get(stage or "", 0)
def list_actions_for_session(funnel_stage: str | None, is_assisting: bool, role: str) -> list[dict]:
rank = funnel_rank(funnel_stage)
out = []
for action_id, spec in DESK_ACTIONS.items():
if rank < spec["min_rank"]:
continue
if not is_assisting and action_id not in ("dns.revalidate", "account.retry_sync"):
continue
out.append({"id": action_id, "label": spec["label"], "requires_assisting": action_id not in ("dns.revalidate", "account.retry_sync")})
if is_assisting and role in ("super_admin", "ops_lead", "technician"):
out.append({"id": MARK_STEP_ACTION, "label": "Marcar passo concluído", "requires_assisting": True})
if role in ("super_admin", "ops_lead"):
out.append({"id": ABORT_ACTION, "label": "Abortar onboarding", "requires_assisting": False, "danger": True})
return out
def build_vm112_request(action_id: str, domain: str, ticket_payload: dict | None) -> tuple[str, str, dict | None]:
if action_id not in DESK_ACTIONS:
raise ValueError(f"acção desconhecida: {action_id}")
spec = DESK_ACTIONS[action_id]
path = spec["path"].format(domain=domain)
body = None
if "body" in spec:
body = spec["body"](domain, ticket_payload or {})
return spec["method"], path, body
def external_links(domain: str | None) -> list[dict[str, str]]:
dom = (domain or "").strip().lower()
links = [
{"id": "proxmox", "label": "Proxmox", "url": PROXMOX_URL, "system": "proxmox"},
{"id": "traefik", "label": "Traefik", "url": TRAEFIK_URL, "system": "traefik"},
{"id": "carbonio", "label": "Carbonio Admin", "url": CARBONIO_ADMIN, "system": "carbonio"},
]
if dom:
links.append({
"id": "webmail",
"label": f"Webmail {dom}",
"url": f"https://mail.{dom}/",
"system": "carbonio",
})
links.append({
"id": "cloudflare",
"label": f"Cloudflare DNS ({dom})",
"url": f"https://dash.cloudflare.com/?to=/:{dom}/dns",
"system": "cloudflare",
})
return links
def technician_ranking(conn: sqlite3.Connection, window_days: int = 30) -> list[dict[str, Any]]:
cutoff = (datetime.now(timezone.utc) - timedelta(days=window_days)).isoformat()
rows = conn.execute(
"""
SELECT aa.actor, aa.action, aa.created_at, s.initiated_by_user
FROM assist_actions aa
LEFT JOIN assist_sessions s ON s.id = aa.assist_session_id
WHERE aa.created_at >= ?
ORDER BY aa.id DESC
""",
(cutoff,),
).fetchall()
skip_actors = frozenset({"client", "system", "worker", ""})
stats: dict[str, dict[str, int]] = defaultdict(
lambda: {"escalados": 0, "assumidos": 0, "handoffs": 0, "acoes": 0, "movimentos": 0}
)
for row in rows:
actor = (row["actor"] or "").strip()
if actor in skip_actors:
continue
action = row["action"] or ""
bucket = stats[actor]
bucket["movimentos"] += 1
if action == "escalate":
bucket["escalados"] += 1
elif action == "takeover":
bucket["assumidos"] += 1
elif action == "handoff":
bucket["handoffs"] += 1
elif action.startswith("action."):
bucket["acoes"] += 1
assigned = conn.execute(
"""
SELECT assigned_to, COUNT(*) c
FROM tickets
WHERE assigned_to IS NOT NULL AND assigned_at >= ?
GROUP BY assigned_to
""",
(cutoff,),
).fetchall()
for row in assigned:
user = (row["assigned_to"] or "").strip()
if user:
stats[user]["atribuidos"] = int(row["c"])
ranking = []
for username, data in stats.items():
assumidos = data["assumidos"]
escalados = data["escalados"]
handoffs = data["handoffs"]
acoes = data["acoes"]
movimentos = data["movimentos"]
atribuidos = data.get("atribuidos", 0)
score = assumidos * 5 + escalados * 2 + acoes * 3 + handoffs + atribuidos
ranking.append({
"username": username,
"assumidos": assumidos,
"escalados": escalados,
"handoffs": handoffs,
"acoes": acoes,
"movimentos": movimentos,
"atribuidos": atribuidos,
"score": score,
})
ranking.sort(key=lambda x: (x["score"], x["assumidos"], x["movimentos"]), reverse=True)
return ranking

554
api/app/assist_routes.py Normal file
View file

@ -0,0 +1,554 @@
"""Assist / takeover API — Spec 010 Phase A."""
from __future__ import annotations
import os
import secrets
from datetime import datetime, timezone
import httpx
from fastapi import APIRouter, Depends, HTTPException
from app import assist_catalog, assist_store, auth
from app.permissions import (
can_assist_handoff,
can_assist_takeover,
can_read_assist,
)
router = APIRouter(prefix="/api/v1/assist", tags=["assist"])
VM112_ASSIST_API = os.getenv("VM112_ASSIST_API_URL", os.getenv("VM112_API_URL", "http://10.10.10.112:8090"))
VM112_ASSIST_TOKEN = os.getenv("VM112_ASSIST_SERVICE_TOKEN", "")
DESK_ASSIST_ENABLED = os.getenv("DESK_ASSIST_ENABLED", "true").lower() in ("1", "true", "yes")
VM112_ASSIST_CALL = os.getenv("VM112_ASSIST_CALL_VM112", "false").lower() in ("1", "true", "yes")
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _main():
from app import main as m
return m
def _session_meta(conn, session_id: str) -> dict:
m = _main()
meta = assist_store.session_funnel_meta(
conn, session_id, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
)
if not meta.get("session_id"):
raise HTTPException(404, "sessão não encontrada no funil")
if meta["funnel_rank"] == 0 and not meta.get("last_event_at"):
raise HTTPException(404, "sessão não encontrada no funil")
return meta
def _parse_payload(raw: str | None) -> dict:
m = _main()
return m._parse_payload(raw)
def _build_session_view(conn, session_id: str, user: auth.DeskUser) -> dict:
m = _main()
meta = _session_meta(conn, session_id)
assist = assist_store.get_active_assist(conn, session_id)
ticket_row = assist_store.find_ticket_by_session(conn, session_id)
ticket = None
if ticket_row:
full = conn.execute(
f"SELECT {m.TICKET_COLUMNS}, session_id, assist_mode, assisted_by, assisted_at, client_paused FROM tickets WHERE id = ?",
(int(ticket_row["id"]),),
).fetchone()
if full:
ticket = m._visible_ticket(m._enrich_ticket(full), user)
view = {
**meta,
"assist_status": assist["status"] if assist else None,
"assist_session_id": assist["id"] if assist else None,
"assisted_by": assist.get("initiated_by_user") if assist else (ticket or {}).get("assisted_by"),
"ticket_id": int(ticket_row["id"]) if ticket_row else None,
"ticket_status": ticket_row["status"] if ticket_row else None,
"assigned_to": ticket_row["assigned_to"] if ticket_row else None,
"can_takeover": meta["can_escalate"] and can_assist_takeover(user.role) and not (assist and assist.get("status") == "active"),
}
if ticket:
view["ticket"] = ticket
return view
def _call_vm112(
method: str,
path: str,
json_body: dict | None = None,
session_id: str | None = None,
) -> dict | None:
if not VM112_ASSIST_CALL:
return None
headers: dict[str, str] = {}
if VM112_ASSIST_TOKEN:
headers["X-Desk-Assist-Token"] = VM112_ASSIST_TOKEN
if session_id:
headers["X-Onboarding-Session"] = session_id
try:
with httpx.Client(timeout=30.0) as client:
r = client.request(method, f"{VM112_ASSIST_API.rstrip('/')}{path}", json=json_body, headers=headers)
if r.status_code >= 400:
return {"error": r.text[:200], "http_status": r.status_code}
return r.json() if r.headers.get("content-type", "").startswith("application/json") else {"ok": True}
except Exception as exc:
return {"error": str(exc)}
@router.get("/sessions")
def list_assist_sessions(user: auth.DeskUser = Depends(auth.get_current_user)):
if not DESK_ASSIST_ENABLED:
raise HTTPException(503, "assistência desactivada")
if not can_read_assist(user.role):
raise HTTPException(403, "permissão insuficiente")
m = _main()
with m.db() as conn:
funnel = m._funnel_summary(conn, window_hours=48)
session_ids = [s["session_id"] for s in funnel.get("active_sessions", []) if s.get("session_id")]
assist_map = assist_store.get_assist_state_map(conn, session_ids)
sessions = []
for item in funnel.get("active_sessions", []):
sid = item.get("session_id")
if not sid:
continue
assist = assist_map.get(sid)
ticket_row = assist_store.find_ticket_by_session(conn, sid)
status = "observing"
if assist and assist.get("status") == "active":
status = "assisting"
elif ticket_row and ticket_row["status"] in ("escalated", "assisting"):
status = ticket_row["status"]
meta = assist_store.session_funnel_meta(
conn, sid, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
)
sessions.append({
**item,
"assist_status": status,
"assisted_by": assist.get("initiated_by_user") if assist else None,
"assigned_to": ticket_row["assigned_to"] if ticket_row else None,
"can_escalate": meta.get("can_escalate", False),
})
return {"sessions": sessions, "window_hours": funnel.get("window_hours", 48)}
@router.get("/sessions/{session_id}")
def get_assist_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_read_assist(user.role):
raise HTTPException(403, "permissão insuficiente")
sid = session_id.strip()
m = _main()
with m.db() as conn:
view = _build_session_view(conn, sid, user)
timeline = m._session_timeline(conn, sid)
from app.funnel_timing import apply_module_timing
enriched, timing_meta = apply_module_timing(timeline)
view["timeline"] = enriched
if timing_meta:
view["timing"] = timing_meta
actions = []
if view.get("assist_session_id"):
rows = conn.execute(
"""
SELECT actor, action, payload, created_at
FROM assist_actions WHERE assist_session_id = ?
ORDER BY id ASC LIMIT 50
""",
(view["assist_session_id"],),
).fetchall()
actions = [
{
"actor": r["actor"],
"action": r["action"],
"payload": _parse_payload(r["payload"]),
"created_at": r["created_at"],
}
for r in rows
]
view["actions"] = actions
return view
@router.post("/sessions/{session_id}/escalate")
def escalate_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_assist_takeover(user.role):
raise HTTPException(403, "permissão insuficiente")
sid = session_id.strip()
now = _now()
m = _main()
with m.db() as conn:
meta = _session_meta(conn, sid)
if not meta["can_escalate"]:
raise HTTPException(400, "escalada só após validação de domínio")
active = assist_store.get_active_assist(conn, sid)
open_assist = assist_store.get_open_assist(conn, sid)
if active:
raise HTTPException(
409,
detail={
"message": "sessão já em assistência",
"assisted_by": active.get("initiated_by_user"),
},
)
if open_assist and open_assist.get("status") == "escalated" and open_assist.get("initiated_by_user") not in (None, user.username):
raise HTTPException(
409,
detail={
"message": "sessão já escalada por outro técnico",
"assisted_by": open_assist.get("initiated_by_user"),
},
)
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, meta.get("domain"))
conn.execute(
"""
UPDATE tickets
SET status = 'escalated', assigned_to = ?, assigned_at = ?, session_id = ?
WHERE id = ?
""",
(user.username, now, sid, ticket_id),
)
if open_assist and open_assist.get("status") == "escalated":
assist_id = int(open_assist["id"])
conn.execute(
"UPDATE assist_sessions SET initiated_by_user = ?, initiated_by = 'technician' WHERE id = ?",
(user.username, assist_id),
)
else:
cur = conn.execute(
"""
INSERT INTO assist_sessions
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain, started_at)
VALUES (?, ?, 'technician', ?, 'escalated', ?, ?, ?)
""",
(sid, ticket_id, user.username, meta.get("funnel_stage"), meta.get("domain"), now),
)
assist_id = int(cur.lastrowid)
assist_store.log_action(conn, assist_id, user.username, "escalate", {"source": "desk"})
conn.commit()
view = _build_session_view(conn, sid, user)
return {"status": "escalated", "ticket_id": ticket_id, "session": view}
@router.post("/sessions/{session_id}/takeover")
def takeover_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_assist_takeover(user.role):
raise HTTPException(403, "permissão insuficiente")
sid = session_id.strip()
now = _now()
m = _main()
with m.db() as conn:
meta = _session_meta(conn, sid)
if not meta["can_escalate"]:
raise HTTPException(400, "takeover só após validação de domínio")
active = assist_store.get_active_assist(conn, sid)
open_assist = assist_store.get_open_assist(conn, sid)
if active and active.get("initiated_by_user") not in (None, user.username):
raise HTTPException(
409,
detail={
"message": "sessão já assumida por outro técnico",
"assisted_by": active.get("initiated_by_user"),
},
)
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, meta.get("domain"))
token_hash = assist_store.hash_token(secrets.token_urlsafe(32))
if open_assist and open_assist.get("status") == "escalated":
assist_id = int(open_assist["id"])
conn.execute(
"""
UPDATE assist_sessions
SET status = 'active', initiated_by_user = ?, takeover_token_hash = ?
WHERE id = ?
""",
(user.username, token_hash, assist_id),
)
elif active:
assist_id = int(active["id"])
else:
cur = conn.execute(
"""
INSERT INTO assist_sessions
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain,
takeover_token_hash, started_at)
VALUES (?, ?, 'technician', ?, 'active', ?, ?, ?, ?)
""",
(sid, ticket_id, user.username, meta.get("funnel_stage"), meta.get("domain"), token_hash, now),
)
assist_id = int(cur.lastrowid)
conn.execute(
"""
UPDATE tickets
SET status = 'assisting', assigned_to = ?, assigned_at = ?, session_id = ?,
assist_mode = 'asm', assisted_by = ?, assisted_at = ?, client_paused = 1
WHERE id = ?
""",
(user.username, now, sid, user.username, now, ticket_id),
)
assist_store.log_action(conn, assist_id, user.username, "takeover", {"phase": "A"})
conn.commit()
_call_vm112("POST", f"/onboarding/sessions/{sid}/pause", session_id=sid)
vm112_result = _call_vm112(
"POST",
f"/onboarding/sessions/{sid}/takeover",
{"technician": user.username},
session_id=sid,
)
if vm112_result and vm112_result.get("takeover_url"):
takeover_url = vm112_result["takeover_url"]
else:
base = os.getenv("VM112_WIZARD_URL", "https://onboard.ibytera.com")
takeover_url = f"{base.rstrip('/')}/assist/{sid}?desk=1"
return {
"status": "assisting",
"ticket_id": ticket_id,
"takeover_url": takeover_url,
"client_paused": True,
"vm112": vm112_result,
"note": "ASM activo — wizard VM112 pausado para o cliente",
}
@router.post("/sessions/{session_id}/handoff")
def handoff_session(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_assist_handoff(user.role, user.username):
raise HTTPException(403, "permissão insuficiente")
sid = session_id.strip()
now = _now()
m = _main()
with m.db() as conn:
active = assist_store.get_active_assist(conn, sid)
if not active:
raise HTTPException(404, "nenhuma assistência activa")
if user.role == "technician" and active.get("initiated_by_user") not in (None, user.username):
raise HTTPException(403, "sessão de outro técnico")
ticket_row = assist_store.find_ticket_by_session(conn, sid)
ticket_id = int(ticket_row["id"]) if ticket_row else active.get("ticket_id")
conn.execute(
"UPDATE assist_sessions SET status = 'ended', ended_at = ? WHERE id = ?",
(now, int(active["id"])),
)
if ticket_id:
conn.execute(
"""
UPDATE tickets
SET status = 'resolved', client_paused = 0, assist_mode = NULL
WHERE id = ?
""",
(ticket_id,),
)
assist_store.log_action(conn, int(active["id"]), user.username, "handoff", {})
conn.commit()
_call_vm112("POST", f"/onboarding/sessions/{sid}/resume", session_id=sid)
return {"status": "handoff", "ticket_id": ticket_id}
@router.get("/sessions/{session_id}/links")
def session_external_links(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_read_assist(user.role):
raise HTTPException(403, "permissão insuficiente")
sid = session_id.strip()
m = _main()
with m.db() as conn:
meta = _session_meta(conn, sid)
return {
"session_id": sid,
"domain": meta.get("domain"),
"links": assist_catalog.external_links(meta.get("domain")),
}
@router.get("/sessions/{session_id}/actions")
def list_session_actions(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_read_assist(user.role):
raise HTTPException(403, "permissão insuficiente")
sid = session_id.strip()
m = _main()
with m.db() as conn:
meta = _session_meta(conn, sid)
assist = assist_store.get_active_assist(conn, sid)
ticket_row = assist_store.find_ticket_by_session(conn, sid)
is_assisting = bool(assist and assist.get("status") == "active") or (
ticket_row and ticket_row["status"] == "assisting"
)
actions = assist_catalog.list_actions_for_session(
meta.get("funnel_stage"), is_assisting, user.role
)
return {"session_id": sid, "actions": actions, "is_assisting": is_assisting}
@router.post("/sessions/{session_id}/actions/{action_id}")
def run_session_action(
session_id: str,
action_id: str,
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_assist_takeover(user.role):
raise HTTPException(403, "permissão insuficiente")
sid = session_id.strip()
action = action_id.strip()
now = _now()
m = _main()
with m.db() as conn:
meta = _session_meta(conn, sid)
domain = (meta.get("domain") or "").strip()
if not domain:
raise HTTPException(400, "domínio não disponível na sessão")
assist = assist_store.get_active_assist(conn, sid)
open_assist = assist_store.get_open_assist(conn, sid)
assist_id = int(assist["id"]) if assist else (int(open_assist["id"]) if open_assist else None)
ticket_row = assist_store.find_ticket_by_session(conn, sid)
ticket_payload = _parse_payload(ticket_row["payload"]) if ticket_row else {}
is_assisting = bool(assist and assist.get("status") == "active")
if action == assist_catalog.ABORT_ACTION:
if user.role not in ("super_admin", "ops_lead"):
raise HTTPException(403, "abortar só ops_lead+")
if ticket_row:
conn.execute(
"UPDATE tickets SET status = 'closed', client_paused = 0, assist_mode = NULL WHERE id = ?",
(int(ticket_row["id"]),),
)
if assist:
conn.execute(
"UPDATE assist_sessions SET status = 'ended', ended_at = ? WHERE id = ?",
(now, int(assist["id"])),
)
if assist_id:
assist_store.log_action(conn, assist_id, user.username, f"action.{action}", {"domain": domain})
conn.commit()
_call_vm112("POST", f"/onboarding/sessions/{sid}/resume", session_id=sid)
return {"status": "aborted", "action": action, "session_id": sid}
if action == assist_catalog.MARK_STEP_ACTION:
if not is_assisting:
raise HTTPException(400, "acção só em modo assistindo")
if assist_id:
assist_store.log_action(conn, assist_id, user.username, f"action.{action}", {"domain": domain})
conn.commit()
return {"status": "ok", "action": action, "note": "passo marcado no audit log"}
if action not in assist_catalog.DESK_ACTIONS:
raise HTTPException(400, f"acção inválida: {action}")
spec = assist_catalog.DESK_ACTIONS[action]
if assist_catalog.funnel_rank(meta.get("funnel_stage")) < spec["min_rank"]:
raise HTTPException(400, "etapa do funil insuficiente para esta acção")
if action not in ("dns.revalidate", "account.retry_sync") and not is_assisting:
raise HTTPException(400, "assuma a sessão antes desta acção")
method, path, body = assist_catalog.build_vm112_request(action, domain, ticket_payload)
vm112_result = _call_vm112(method, path, body, session_id=sid)
if not assist_id:
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, domain)
cur = conn.execute(
"""
INSERT INTO assist_sessions
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain, started_at)
VALUES (?, ?, 'technician', ?, 'escalated', ?, ?, ?)
""",
(sid, ticket_id, user.username, meta.get("funnel_stage"), domain, now),
)
assist_id = int(cur.lastrowid)
assist_store.log_action(
conn,
assist_id,
user.username,
f"action.{action}",
{"domain": domain, "vm112": vm112_result},
)
conn.commit()
if vm112_result is None and not VM112_ASSIST_CALL:
return {
"status": "logged",
"action": action,
"note": "VM112_ASSIST_CALL_VM112=false — acção registada no audit",
}
if vm112_result and vm112_result.get("http_status", 0) >= 400:
raise HTTPException(502, detail={"message": "VM112 rejeitou acção", "vm112": vm112_result})
return {"status": "ok", "action": action, "vm112": vm112_result}
@router.get("/technicians/ranking")
def technicians_ranking(
window_days: int = 30,
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_read_assist(user.role):
raise HTTPException(403, "permissão insuficiente")
if user.role == "noc":
raise HTTPException(403, "permissão insuficiente")
days = max(1, min(window_days, 365))
m = _main()
with m.db() as conn:
ranking = assist_catalog.technician_ranking(conn, window_days=days)
return {"window_days": days, "ranking": ranking, "total": len(ranking)}
def process_assist_started(conn, body, now: str) -> dict:
m = _main()
sid = (body.session_id or "").strip()
technician = (body.data or {}).get("technician")
meta = assist_store.session_funnel_meta(
conn, sid, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
)
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, body.domain or meta.get("domain"))
conn.execute(
"""
UPDATE tickets
SET status = 'assisting', session_id = ?, assist_mode = 'asm',
assisted_by = ?, assisted_at = ?, client_paused = 1
WHERE id = ?
""",
(sid, technician, now, ticket_id),
)
return {"handled": True, "ticket_id": ticket_id, "session_id": sid}
def process_assist_ended(conn, body, now: str) -> dict:
sid = (body.session_id or "").strip()
ticket_row = assist_store.find_ticket_by_session(conn, sid)
if ticket_row:
conn.execute(
"UPDATE tickets SET status = 'resolved', client_paused = 0, assist_mode = NULL WHERE id = ?",
(int(ticket_row["id"]),),
)
return {"handled": True, "ticket_id": int(ticket_row["id"]), "session_id": sid}
return {"handled": False, "session_id": sid}
def process_escalation_webhook(conn, body, now: str) -> dict:
"""Handle onboarding.escalated / onboarding.failed from VM112."""
m = _main()
sid = (body.session_id or "").strip()
if not sid:
return {"handled": False, "reason": "missing session_id"}
meta = assist_store.session_funnel_meta(
conn, sid, m.FUNNEL_EVENT_RANK, m.FUNNEL_STAGE_BY_RANK, m.ONBOARD_SOURCE
)
ticket_id = assist_store.ensure_onboard_ticket(conn, sid, body.domain or meta.get("domain"))
conn.execute(
"UPDATE tickets SET status = 'escalated', session_id = ? WHERE id = ?",
(sid, ticket_id),
)
if not assist_store.get_open_assist(conn, sid):
cur = conn.execute(
"""
INSERT INTO assist_sessions
(session_id, ticket_id, initiated_by, initiated_by_user, status, funnel_stage, domain, started_at)
VALUES (?, ?, 'client', NULL, 'escalated', ?, ?, ?)
""",
(sid, ticket_id, meta.get("funnel_stage"), body.domain or meta.get("domain"), now),
)
assist_store.log_action(conn, int(cur.lastrowid), "client", "escalate", {"event": body.event})
return {"handled": True, "ticket_id": ticket_id, "session_id": sid}

239
api/app/assist_store.py Normal file
View file

@ -0,0 +1,239 @@
"""Assist / takeover session storage — Spec 010 Phase A."""
from __future__ import annotations
import hashlib
import json
import sqlite3
from datetime import datetime, timezone
from typing import Any
ASSIST_MIN_RANK = 2 # domain.validated
ASSIST_MIN_STAGE = "domain_validated"
TICKET_ASSIST_STATUSES = frozenset({"open", "escalated", "assisting", "resolved", "closed"})
TICKET_ACTIVE_STATUSES = frozenset({"open", "escalated", "assisting", "resolved"})
def init_assist_schema(conn: sqlite3.Connection) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS assist_sessions (
id INTEGER PRIMARY KEY,
session_id TEXT NOT NULL,
ticket_id INTEGER,
initiated_by TEXT NOT NULL,
initiated_by_user TEXT,
status TEXT NOT NULL,
funnel_stage TEXT,
domain TEXT,
takeover_token_hash TEXT,
started_at TEXT NOT NULL,
ended_at TEXT,
audit_summary TEXT
);
CREATE INDEX IF NOT EXISTS idx_assist_sessions_session
ON assist_sessions(session_id);
CREATE TABLE IF NOT EXISTS assist_actions (
id INTEGER PRIMARY KEY,
assist_session_id INTEGER NOT NULL,
actor TEXT NOT NULL,
action TEXT NOT NULL,
payload TEXT,
created_at TEXT NOT NULL
);
"""
)
cols = {row[1] for row in conn.execute("PRAGMA table_info(tickets)").fetchall()}
for col, ddl in (
("session_id", "TEXT"),
("assist_mode", "TEXT"),
("assisted_by", "TEXT"),
("assisted_at", "TEXT"),
("client_paused", "INTEGER NOT NULL DEFAULT 0"),
):
if col not in cols:
conn.execute(f"ALTER TABLE tickets ADD COLUMN {col} {ddl}")
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_payload(raw: str | None) -> dict:
if not raw:
return {}
try:
return json.loads(raw)
except json.JSONDecodeError:
return {}
def log_action(
conn: sqlite3.Connection,
assist_session_id: int,
actor: str,
action: str,
payload: dict | None = None,
) -> None:
conn.execute(
"""
INSERT INTO assist_actions (assist_session_id, actor, action, payload, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(assist_session_id, actor, action, json.dumps(payload or {}), _now()),
)
def get_active_assist(conn: sqlite3.Connection, session_id: str) -> dict | None:
sid = (session_id or "").strip()
if not sid:
return None
row = conn.execute(
"""
SELECT * FROM assist_sessions
WHERE session_id = ? AND status = 'active' AND ended_at IS NULL
ORDER BY id DESC LIMIT 1
""",
(sid,),
).fetchone()
return dict(row) if row else None
def get_open_assist(conn: sqlite3.Connection, session_id: str) -> dict | None:
"""Sessão de assistência aberta (escalated ou active)."""
sid = (session_id or "").strip()
if not sid:
return None
row = conn.execute(
"""
SELECT * FROM assist_sessions
WHERE session_id = ? AND ended_at IS NULL AND status IN ('escalated', 'active')
ORDER BY id DESC LIMIT 1
""",
(sid,),
).fetchone()
return dict(row) if row else None
def get_assist_state_map(conn: sqlite3.Connection, session_ids: list[str]) -> dict[str, dict]:
if not session_ids:
return {}
placeholders = ",".join("?" * len(session_ids))
rows = conn.execute(
f"""
SELECT a.* FROM assist_sessions a
INNER JOIN (
SELECT session_id, MAX(id) AS max_id
FROM assist_sessions
WHERE session_id IN ({placeholders}) AND ended_at IS NULL
GROUP BY session_id
) latest ON a.id = latest.max_id
""",
session_ids,
).fetchall()
out: dict[str, dict] = {}
for row in rows:
item = dict(row)
out[item["session_id"]] = item
return out
def session_funnel_meta(
conn: sqlite3.Connection,
session_id: str,
funnel_event_rank: dict[str, int],
funnel_stage_by_rank: dict[int, str],
onboard_source: str = "vm112-onboard",
) -> dict[str, Any]:
sid = (session_id or "").strip()
rows = conn.execute(
"""
SELECT event_type, payload, created_at
FROM webhook_events
WHERE source = ?
ORDER BY id ASC
""",
(onboard_source,),
).fetchall()
max_rank = 0
domain = None
failed = False
last_event_at = None
for row in rows:
payload = _parse_payload(row["payload"])
if (payload.get("session_id") or "").strip() != sid:
continue
if payload.get("domain"):
domain = payload.get("domain")
last_event_at = row["created_at"]
if row["event_type"] == "onboarding.failed":
failed = True
max_rank = max(max_rank, 99)
else:
rank = funnel_event_rank.get(row["event_type"], 0)
if not failed:
max_rank = max(max_rank, rank)
if failed:
stage = "failed"
else:
stage = funnel_stage_by_rank.get(max_rank, "started")
return {
"session_id": sid,
"domain": domain,
"funnel_stage": stage,
"funnel_rank": max_rank,
"can_escalate": max_rank >= ASSIST_MIN_RANK or failed,
"last_event_at": last_event_at,
}
def find_ticket_by_session(conn: sqlite3.Connection, session_id: str) -> sqlite3.Row | None:
sid = (session_id or "").strip()
if not sid:
return None
row = conn.execute(
"SELECT id, status, assigned_to, session_id, payload FROM tickets WHERE session_id = ? ORDER BY id DESC LIMIT 1",
(sid,),
).fetchone()
if row:
return row
rows = conn.execute("SELECT id, status, assigned_to, session_id, payload FROM tickets ORDER BY id DESC LIMIT 300").fetchall()
for item in rows:
payload = _parse_payload(item["payload"])
if (payload.get("session_id") or "").strip() == sid:
return item
return None
def ensure_onboard_ticket(
conn: sqlite3.Connection,
session_id: str,
domain: str | None,
tenant_id: int = 1,
) -> int:
existing = find_ticket_by_session(conn, session_id)
if existing:
return int(existing["id"])
now = _now()
payload = json.dumps({
"event": "onboarding.escalated",
"domain": domain,
"session_id": session_id,
"source": "vm112-onboard",
"data": {"reason": "desk_assist"},
})
subject = f"[assist] {domain or 'sem dominio'}{session_id[:12]}"
cur = conn.execute(
"""
INSERT INTO tickets
(tenant_id, subject, status, payload, created_at, session_id, assigned_to, assigned_at)
VALUES (?, ?, 'escalated', ?, ?, ?, NULL, NULL)
""",
(tenant_id, subject, payload, now, session_id),
)
return int(cur.lastrowid)
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()

508
api/app/audit_store.py Normal file
View file

@ -0,0 +1,508 @@
"""SQLite persistence for audit domains and checks."""
from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from typing import Any
from app.collectors.base import CHECK_LABELS
ONBOARD_DOMAIN_EVENTS = frozenset({"account.created", "onboarding.completed"})
TENANT_ONBOARD = 1
TENANT_WEBHOOK_SOURCE = {
1: "vm112-onboard",
2: "wazuh",
}
FUNNEL_EVENT_RANK = {
"onboarding.started": 1,
"domain.validated": 2,
"dns.applied": 3,
"account.created": 4,
"infra.synced": 5,
"onboarding.completed": 6,
"company.validated": 7,
"webmail.released": 8,
"onboarding.failed": 99,
}
FUNNEL_STAGE_BY_RANK = {
1: "started",
2: "domain_validated",
3: "dns_applied",
4: "account_created",
5: "infra_synced",
6: "completed",
7: "company_validated",
8: "webmail_released",
99: "failed",
}
FUNNEL_STAGE_LABELS = {
"started": "Iniciado",
"domain_validated": "Domínio OK",
"dns_applied": "DNS aplicado",
"account_created": "Conta criada",
"infra_synced": "Infra sync",
"completed": "Concluído",
"company_validated": "Empresa validada",
"webmail_released": "Webmail liberado",
"failed": "Falhou",
"registered": "Registado",
"unknown": "Sem dados",
}
STATUS_RANK = {"pass": 0, "skip": 1, "warn": 2, "error": 3, "fail": 4}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_payload(raw: str | None) -> dict:
if not raw:
return {}
try:
return json.loads(raw)
except json.JSONDecodeError:
return {}
def init_audit_schema(conn: sqlite3.Connection) -> None:
conn.executescript("""
CREATE TABLE IF NOT EXISTS audit_domains (
id INTEGER PRIMARY KEY,
tenant_id INTEGER NOT NULL,
domain TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'onboarding',
created_at TEXT NOT NULL,
UNIQUE(tenant_id, domain)
);
CREATE TABLE IF NOT EXISTS audit_checks (
id INTEGER PRIMARY KEY,
tenant_id INTEGER NOT NULL,
domain TEXT NOT NULL,
check_id TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
evidence TEXT,
checked_at TEXT NOT NULL,
UNIQUE(tenant_id, domain, check_id)
);
""")
def sync_domains_from_webhooks(conn: sqlite3.Connection) -> int:
rows = conn.execute(
"""
SELECT event_type, payload FROM webhook_events
WHERE source = 'vm112-onboard'
ORDER BY id DESC LIMIT 500
"""
).fetchall()
added = 0
now = _now()
seen: set[tuple[int, str]] = set()
for row in rows:
if row["event_type"] not in ONBOARD_DOMAIN_EVENTS:
continue
payload = _parse_payload(row["payload"])
domain = (payload.get("domain") or "").strip().lower()
if not domain or len(domain) < 3:
continue
key = (TENANT_ONBOARD, domain)
if key in seen:
continue
seen.add(key)
cur = conn.execute(
"""
INSERT OR IGNORE INTO audit_domains (tenant_id, domain, source, created_at)
VALUES (?, ?, 'onboarding', ?)
""",
(TENANT_ONBOARD, domain, now),
)
if cur.rowcount:
added += 1
conn.commit()
return added
def list_audit_domains(conn: sqlite3.Connection, tenant_id: int | None = None) -> list[dict]:
if tenant_id:
rows = conn.execute(
"SELECT tenant_id, domain, source, created_at FROM audit_domains WHERE tenant_id = ? ORDER BY domain",
(tenant_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT tenant_id, domain, source, created_at FROM audit_domains ORDER BY tenant_id, domain"
).fetchall()
return [dict(r) for r in rows]
def upsert_check(
conn: sqlite3.Connection,
tenant_id: int,
domain: str,
check_id: str,
status: str,
message: str,
evidence: dict | None,
checked_at: str | None = None,
) -> None:
conn.execute(
"""
INSERT INTO audit_checks (tenant_id, domain, check_id, status, message, evidence, checked_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tenant_id, domain, check_id) DO UPDATE SET
status = excluded.status,
message = excluded.message,
evidence = excluded.evidence,
checked_at = excluded.checked_at
""",
(
tenant_id,
domain.lower(),
check_id,
status,
message,
json.dumps(evidence or {}),
checked_at or _now(),
),
)
def get_checks(conn: sqlite3.Connection, tenant_id: int, domain: str) -> list[dict]:
rows = conn.execute(
"""
SELECT check_id, status, message, evidence, checked_at
FROM audit_checks WHERE tenant_id = ? AND domain = ?
ORDER BY check_id
""",
(tenant_id, domain.lower()),
).fetchall()
out = []
for row in rows:
item = dict(row)
item["label"] = CHECK_LABELS.get(item["check_id"], item["check_id"])
item["evidence"] = _parse_payload(item.get("evidence"))
out.append(item)
return out
def aggregate_score(checks: list[dict]) -> dict[str, Any]:
total = len(CHECK_LABELS)
counts = {"pass": 0, "warn": 0, "fail": 0, "error": 0, "skip": 0}
worst = "pass"
for c in checks:
st = c.get("status") or "skip"
counts[st] = counts.get(st, 0) + 1
if STATUS_RANK.get(st, 0) > STATUS_RANK.get(worst, 0):
worst = st
if worst in ("fail", "error"):
overall = "critical"
elif worst == "warn":
overall = "degraded"
elif checks:
overall = "healthy"
else:
overall = "unknown"
return {
"pass": counts.get("pass", 0),
"warn": counts.get("warn", 0),
"fail": counts.get("fail", 0),
"error": counts.get("error", 0),
"skip": counts.get("skip", 0),
"total": total,
"overall_status": overall,
}
def tenant_overview(conn: sqlite3.Connection, tenant_id: int, name: str, ip: str) -> dict:
if tenant_id == 2:
from app.modules import store as module_store
if module_store.is_module_enabled("wazuh-soc"):
from app.wazuh_soc_store import wazuh_tenant_overview
return wazuh_tenant_overview(conn, tenant_id, name, ip)
domains = list_audit_domains(conn, tenant_id)
if not domains:
return {
"tenant_id": tenant_id,
"name": name,
"ip": ip,
"status": "unknown",
"score": {"pass": 0, "warn": 0, "fail": 0, "total": 8},
"domains_count": 0,
"last_audit_at": None,
"top_issues": [],
}
all_checks: list[dict] = []
last_audit = None
top_issues: list[dict] = []
domain_scores: list[dict] = []
for d in domains:
checks = get_checks(conn, tenant_id, d["domain"])
if not checks:
continue
all_checks.extend(checks)
domain_scores.append(aggregate_score(checks))
for c in checks:
if c["checked_at"] and (not last_audit or c["checked_at"] > last_audit):
last_audit = c["checked_at"]
if c["status"] in ("fail", "error", "warn"):
top_issues.append({
"domain": d["domain"],
"check_id": c["check_id"],
"status": c["status"],
"message": c.get("message"),
})
if domain_scores:
worst = max(domain_scores, key=lambda s: STATUS_RANK.get(s["overall_status"], 0))
score = worst
else:
score = aggregate_score(all_checks)
return {
"tenant_id": tenant_id,
"name": name,
"ip": ip,
"status": score["overall_status"],
"score": {
"pass": score["pass"],
"warn": score["warn"],
"fail": score["fail"] + score["error"],
"total": score["total"],
},
"domains_count": len(domains),
"last_audit_at": last_audit,
"top_issues": top_issues[:5],
}
def build_overview(conn: sqlite3.Connection) -> dict:
tenants = conn.execute("SELECT id, name, ip FROM tenants ORDER BY id").fetchall()
return {
"generated_at": _now(),
"tenants": [tenant_overview(conn, t["id"], t["name"], t["ip"]) for t in tenants],
}
def scorecard(conn: sqlite3.Connection, tenant_id: int, domain: str) -> dict:
domain = domain.lower().strip()
checks = get_checks(conn, tenant_id, domain)
score = aggregate_score(checks)
return {
"tenant_id": tenant_id,
"domain": domain,
"checked_at": max((c["checked_at"] for c in checks), default=None),
"overall_status": score["overall_status"],
"checks": checks,
}
def _extract_client_ip(payload: dict, data: dict | None = None) -> str | None:
data = data or {}
for key in ("client_ip", "user_ip", "remote_ip", "srcip", "ip", "agent_ip"):
val = data.get(key) or payload.get(key)
if val:
return str(val)
ingress = payload.get("ingress_client_ip")
return str(ingress) if ingress else None
def _funnel_stage_from_events(events: list[dict]) -> str:
best_rank = 0
for ev in events:
rank = FUNNEL_EVENT_RANK.get(ev.get("event") or "", 0)
if rank > best_rank:
best_rank = rank
if best_rank:
return FUNNEL_STAGE_BY_RANK.get(best_rank, "unknown")
return "registered"
def _execution_status(events: list[dict]) -> str:
types = {ev.get("event") for ev in events}
if "onboarding.failed" in types:
return "failed"
if "onboarding.completed" in types:
return "completed"
if types & set(FUNNEL_EVENT_RANK):
return "in_progress"
if events:
return "in_progress"
return "registered"
def _tickets_for_domain(conn: sqlite3.Connection, domain: str) -> list[dict]:
dom = domain.lower().strip()
rows = conn.execute(
"""
SELECT id, subject, status, session_id, payload, created_at
FROM tickets ORDER BY id DESC LIMIT 500
"""
).fetchall()
out = []
for row in rows:
payload = _parse_payload(row["payload"])
if (payload.get("domain") or "").strip().lower() != dom:
continue
data = payload.get("data") or {}
out.append({
"ticket_id": row["id"],
"status": row["status"],
"subject": row["subject"],
"session_id": row["session_id"] or payload.get("session_id"),
"email": data.get("email") or payload.get("account_email"),
"crm_track": payload.get("crm_track"),
"created_at": row["created_at"],
})
return out
def _domain_webhook_events(conn: sqlite3.Connection, source: str | None, domain: str) -> list[dict]:
if not source:
return []
dom = domain.lower().strip()
rows = conn.execute(
"""
SELECT event_type, payload, created_at FROM webhook_events
WHERE source = ?
ORDER BY created_at ASC
""",
(source,),
).fetchall()
events = []
for row in rows:
payload = _parse_payload(row["payload"])
if (payload.get("domain") or "").strip().lower() != dom:
continue
data = payload.get("data") or {}
client_ip = _extract_client_ip(payload, data)
detail = data.get("step") or data.get("description") or data.get("agent")
if source == "wazuh" and not client_ip:
client_ip = data.get("agent_ip") or data.get("srcip")
events.append({
"event": row["event_type"],
"at": row["created_at"],
"session_id": payload.get("session_id"),
"email": data.get("email"),
"client_ip": client_ip,
"detail": detail,
})
return events
def _domain_detail(conn: sqlite3.Connection, tenant_id: int, domain_row: dict) -> dict:
domain = domain_row["domain"]
checks = get_checks(conn, tenant_id, domain)
score = aggregate_score(checks)
issues = [
{
"check_id": c["check_id"],
"label": c.get("label") or CHECK_LABELS.get(c["check_id"], c["check_id"]),
"status": c["status"],
"message": c.get("message"),
"checked_at": c.get("checked_at"),
"evidence": c.get("evidence") or {},
}
for c in checks
if c.get("status") in ("fail", "error", "warn")
]
source = TENANT_WEBHOOK_SOURCE.get(tenant_id)
timeline = _domain_webhook_events(conn, source, domain)
tickets = _tickets_for_domain(conn, domain)
ticket = tickets[0] if tickets else None
funnel_stage = _funnel_stage_from_events(timeline)
execution_status = _execution_status(timeline)
client_ips = sorted({ev["client_ip"] for ev in timeline if ev.get("client_ip")})
last_event = timeline[-1] if timeline else None
started_at = timeline[0]["at"] if timeline else domain_row.get("created_at")
return {
"domain": domain,
"source": domain_row.get("source"),
"registered_at": domain_row.get("created_at"),
"email": (last_event or {}).get("email") or (ticket or {}).get("email"),
"session_id": (last_event or {}).get("session_id") or (ticket or {}).get("session_id"),
"client_ip": client_ips[-1] if client_ips else None,
"client_ips": client_ips,
"funnel_stage": funnel_stage,
"funnel_stage_label": FUNNEL_STAGE_LABELS.get(funnel_stage, funnel_stage),
"execution_status": execution_status,
"last_event": (last_event or {}).get("event"),
"last_event_at": (last_event or {}).get("at"),
"started_at": started_at,
"audit_status": score["overall_status"],
"score": {
"pass": score["pass"],
"warn": score["warn"],
"fail": score["fail"] + score["error"],
"total": score["total"],
},
"issue_count": len(issues),
"issues": issues,
"ticket_id": (ticket or {}).get("ticket_id"),
"ticket_status": (ticket or {}).get("status"),
"tickets_count": len(tickets),
"timeline": timeline,
"last_audit_at": max((c["checked_at"] for c in checks), default=None),
}
def _apply_funnel_timing_to_domains(domain_details: list[dict]) -> None:
from app.funnel_timing import apply_module_timing
for domain in domain_details:
timeline = domain.get("timeline") or []
if not timeline:
continue
enriched, timing_meta = apply_module_timing(timeline)
domain["timeline"] = enriched
if timing_meta:
domain["timing"] = timing_meta
def tenant_details(conn: sqlite3.Connection, tenant_id: int) -> dict | None:
row = conn.execute("SELECT id, name, ip FROM tenants WHERE id = ?", (tenant_id,)).fetchone()
if not row:
return None
if tenant_id == 2:
from app.modules import store as module_store
if module_store.is_module_enabled("wazuh-soc"):
from app.wazuh_soc_store import wazuh_tenant_details
return wazuh_tenant_details(conn, tenant_id, row["name"], row["ip"])
domains = list_audit_domains(conn, tenant_id)
domain_details = [_domain_detail(conn, tenant_id, d) for d in domains]
_apply_funnel_timing_to_domains(domain_details)
summary = {
"domains_total": len(domain_details),
"in_progress": sum(1 for d in domain_details if d["execution_status"] == "in_progress"),
"completed": sum(1 for d in domain_details if d["execution_status"] == "completed"),
"failed": sum(1 for d in domain_details if d["execution_status"] == "failed"),
"registered": sum(1 for d in domain_details if d["execution_status"] == "registered"),
"with_issues": sum(1 for d in domain_details if d["issue_count"] > 0),
}
result = {
"tenant_id": tenant_id,
"name": row["name"],
"ip": row["ip"],
"generated_at": _now(),
"summary": summary,
"domains": domain_details,
}
if tenant_id == 1:
from app.modules import store as module_store
if module_store.is_module_enabled("wizard-security"):
from app import security_store
result["security"] = security_store.build_summary(conn, window_hours=24)
return result

380
api/app/auth.py Normal file
View file

@ -0,0 +1,380 @@
"""Authentication and JWT for Ligbox Ops Desk."""
from __future__ import annotations
import os
import secrets
import sqlite3
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from fastapi import Depends, Header, HTTPException, Request
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.totp_util import verify_code as verify_totp_code
DB_PATH = Path(os.getenv("SQLITE_PATH", "/data/ops.db"))
JWT_SECRET = os.getenv("JWT_SECRET", "ligbox-ops-change-me-in-production")
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = int(os.getenv("JWT_EXPIRE_HOURS", "8"))
DESK_AUTH_ENABLED = os.getenv("DESK_AUTH_ENABLED", "true").lower() in ("1", "true", "yes")
DESK_BOOTSTRAP_PASSWORD = os.getenv("DESK_BOOTSTRAP_PASSWORD", "805353")
AUTH_LOGIN_RATE_LIMIT = int(os.getenv("AUTH_LOGIN_RATE_LIMIT", "5"))
OPS_INTERNAL_TOKEN = os.getenv("OPS_INTERNAL_TOKEN", "")
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
_login_attempts: dict[str, list[float]] = {}
_mfa_pending: dict[str, tuple[str, float]] = {}
MFA_TOKEN_TTL_SEC = 300
SEED_USERS = (
("root", "super_admin", "Roger"),
("admin", "ops_lead", "Chefe Ops"),
("mini", "technician", "Suporte"),
("noc", "noc", "NOC"),
)
@dataclass
class DeskUser:
username: str
role: str
display_name: str | None = None
active: bool = True
@property
def is_authenticated(self) -> bool:
return bool(self.username)
def db() -> sqlite3.Connection:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def init_auth_schema(conn: sqlite3.Connection) -> None:
from app import backup_codes, mfa_recovery_store, registration_store
conn.execute(
"""
CREATE TABLE IF NOT EXISTS desk_users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
display_name TEXT,
active INTEGER NOT NULL DEFAULT 1,
last_login_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
registration_store.init_registration_schema(conn)
backup_codes.init_backup_schema(conn)
mfa_recovery_store.init_recovery_schema(conn)
cols = {row[1] for row in conn.execute("PRAGMA table_info(tickets)").fetchall()}
if "assigned_to" not in cols:
conn.execute("ALTER TABLE tickets ADD COLUMN assigned_to TEXT")
if "assigned_at" not in cols:
conn.execute("ALTER TABLE tickets ADD COLUMN assigned_at TEXT")
count = conn.execute("SELECT COUNT(*) c FROM desk_users").fetchone()["c"]
if count == 0:
now = datetime.now(timezone.utc).isoformat()
for username, role, display_name in SEED_USERS:
conn.execute(
"""
INSERT INTO desk_users
(username, password_hash, role, display_name, active, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?)
""",
(username, hash_password(DESK_BOOTSTRAP_PASSWORD), role, display_name, now, now),
)
def create_access_token(user: DeskUser) -> tuple[str, int]:
expires = timedelta(hours=JWT_EXPIRE_HOURS)
expire_at = datetime.now(timezone.utc) + expires
payload = {
"sub": user.username,
"role": user.role,
"exp": expire_at,
"iat": datetime.now(timezone.utc),
}
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
return token, int(expires.total_seconds())
def decode_token(token: str) -> DeskUser:
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
except JWTError as exc:
raise HTTPException(401, "invalid or expired token") from exc
username = payload.get("sub")
role = payload.get("role")
if not username or role not in {"super_admin", "ops_lead", "technician", "noc"}:
raise HTTPException(401, "invalid token claims")
with db() as conn:
row = conn.execute(
"SELECT username, role, display_name, active FROM desk_users WHERE username = ?",
(username,),
).fetchone()
if not row or not row["active"]:
raise HTTPException(401, "user inactive or not found")
return DeskUser(
username=row["username"],
role=row["role"],
display_name=row["display_name"],
active=bool(row["active"]),
)
def _normalize_login(username: str) -> str:
u = username.strip()
if u.lower() == "root":
return "root"
if "@" in u:
return u.lower()
return u.lower()
def _user_row(login: str) -> sqlite3.Row | None:
with db() as conn:
return conn.execute(
"""
SELECT username, password_hash, role, display_name, active, totp_secret, totp_enabled
FROM desk_users WHERE username = ? OR email = ?
""",
(login, login),
).fetchone()
def check_credentials(username: str, password: str) -> tuple[DeskUser | None, sqlite3.Row | None]:
login = _normalize_login(username)
row = _user_row(login)
if not row or not row["active"]:
return None, None
if not verify_password(password, row["password_hash"]):
return None, None
user = DeskUser(
username=row["username"],
role=row["role"],
display_name=row["display_name"],
active=True,
)
return user, row
def touch_last_login(username: str) -> None:
now = datetime.now(timezone.utc).isoformat()
with db() as conn:
conn.execute(
"UPDATE desk_users SET last_login_at = ?, updated_at = ? WHERE username = ?",
(now, now, username),
)
conn.commit()
def authenticate_user(username: str, password: str) -> DeskUser | None:
user, _row = check_credentials(username, password)
if not user:
return None
touch_last_login(user.username)
return user
def user_requires_totp(row: sqlite3.Row | None) -> bool:
if not row:
return False
return bool(row["totp_enabled"] and row["totp_secret"])
def create_mfa_token(username: str) -> str:
token = secrets.token_urlsafe(32)
_mfa_pending[token] = (username, time.time() + MFA_TOKEN_TTL_SEC)
return token
def peek_mfa_token(token: str) -> str | None:
entry = _mfa_pending.get(token)
if not entry:
return None
username, expires = entry
if time.time() > expires:
_mfa_pending.pop(token, None)
return None
return username
def consume_mfa_token(token: str) -> str | None:
entry = _mfa_pending.pop(token, None)
if not entry:
return None
username, expires = entry
if time.time() > expires:
return None
return username
def verify_user_totp(username: str, code: str) -> bool:
row = _user_row(username)
if not row or not row["totp_secret"]:
return False
return verify_totp_code(row["totp_secret"], code)
def check_login_rate_limit(client_ip: str) -> None:
now = time.time()
window = 60.0
attempts = _login_attempts.setdefault(client_ip, [])
attempts[:] = [t for t in attempts if now - t < window]
if len(attempts) >= AUTH_LOGIN_RATE_LIMIT:
raise HTTPException(429, "too many login attempts")
attempts.append(now)
def _extract_bearer(authorization: str | None) -> str | None:
if not authorization:
return None
parts = authorization.split(" ", 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
return parts[1].strip() or None
def get_current_user_optional(
authorization: str | None = Header(default=None),
) -> DeskUser | None:
if not DESK_AUTH_ENABLED:
return DeskUser(username="system", role="super_admin", display_name="Auth disabled")
token = _extract_bearer(authorization)
if not token:
return None
return decode_token(token)
def get_current_user(user: DeskUser | None = Depends(get_current_user_optional)) -> DeskUser:
if not DESK_AUTH_ENABLED:
return DeskUser(username="system", role="super_admin", display_name="Auth disabled")
if user is None:
raise HTTPException(401, "not authenticated")
return user
def require_internal_or_user(
request: Request,
x_ops_internal_token: str | None = Header(default=None),
user: DeskUser | None = Depends(get_current_user_optional),
) -> DeskUser:
if OPS_INTERNAL_TOKEN and x_ops_internal_token == OPS_INTERNAL_TOKEN:
return DeskUser(username="worker", role="super_admin", display_name="Internal")
if not DESK_AUTH_ENABLED:
return DeskUser(username="system", role="super_admin", display_name="Auth disabled")
if user is None:
raise HTTPException(401, "not authenticated")
return user
def require_roles(*roles: str):
allowed = frozenset(roles)
def dependency(user: DeskUser = Depends(get_current_user)) -> DeskUser:
if user.role not in allowed:
raise HTTPException(403, "insufficient permissions")
return user
return dependency
def mask_value(value: Any) -> Any:
if value is None:
return None
if isinstance(value, str):
return "***" if value else value
return "***"
def mask_company_profile(profile: dict | None) -> dict | None:
if not profile:
return profile
masked = dict(profile)
for key in ("tax_id", "email_billing", "email_legal", "phone_landline", "phone_mobile", "contact_phone"):
if key in masked:
masked[key] = "***"
if "address" in masked:
masked["address"] = {}
return masked
def mask_ticket(ticket: dict) -> dict:
out = dict(ticket)
out["company_profile"] = mask_company_profile(out.get("company_profile"))
out.pop("billing_state", None)
payload = out.get("payload")
if isinstance(payload, dict):
payload = dict(payload)
payload.pop("billing_state", None)
if payload.get("company_profile"):
payload["company_profile"] = mask_company_profile(payload["company_profile"])
notes = payload.get("funnel_notes")
if isinstance(notes, list):
payload["funnel_notes"] = [
{
**note,
"data": {
**(note.get("data") or {}),
"company_profile": mask_company_profile((note.get("data") or {}).get("company_profile")),
},
}
for note in notes
]
out["payload"] = payload
out.pop("email", None)
return out
def mask_summary_for_noc(summary: dict) -> dict:
out = dict(summary)
out["recent_tickets"] = [mask_ticket(t) for t in out.get("recent_tickets", [])]
return out
def user_public_dict(row: sqlite3.Row | dict) -> dict:
if isinstance(row, sqlite3.Row):
row = dict(row)
out = {
"username": row["username"],
"role": row["role"],
"display_name": row.get("display_name"),
"active": bool(row.get("active", 1)),
"last_login_at": row.get("last_login_at"),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
}
if row.get("email"):
out["email"] = row["email"]
if row.get("phone"):
out["phone"] = row["phone"]
if "mfa_enabled" in row:
out["mfa_enabled"] = bool(row.get("mfa_enabled"))
if "totp_enabled" in row:
out["totp_enabled"] = bool(row.get("totp_enabled"))
if "backup_codes_remaining" in row:
out["backup_codes_remaining"] = int(row.get("backup_codes_remaining") or 0)
return out

279
api/app/auth_routes.py Normal file
View file

@ -0,0 +1,279 @@
"""Auth API routes."""
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from pydantic import model_validator
from app import auth, backup_codes, mail_notify
from app.permissions import ROLES, can_manage_users
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
class LoginRequest(BaseModel):
username: str
password: str
class LoginMfaRequest(BaseModel):
mfa_token: str
totp_code: str | None = Field(default=None, min_length=6, max_length=6)
backup_code: str | None = Field(default=None, min_length=8, max_length=12)
@model_validator(mode="after")
def require_one_factor(self):
has_totp = bool(self.totp_code and self.totp_code.strip())
has_backup = bool(self.backup_code and self.backup_code.strip())
if has_totp == has_backup:
raise ValueError("informe o código 2FA ou um código de backup")
return self
class UserUpdateRequest(BaseModel):
role: str | None = None
active: bool | None = None
password: str | None = Field(default=None, min_length=6)
display_name: str | None = None
class ChangePasswordRequest(BaseModel):
current_password: str = Field(min_length=1)
new_password: str = Field(min_length=8)
totp_code: str | None = Field(default=None, min_length=6, max_length=6)
@router.post("/login")
def login(body: LoginRequest, request: Request):
client_ip = request.client.host if request.client else "unknown"
auth.check_login_rate_limit(client_ip)
user, row = auth.check_credentials(body.username, body.password)
if not user:
raise HTTPException(401, "invalid credentials")
if auth.user_requires_totp(row):
mfa_token = auth.create_mfa_token(user.username)
return {
"mfa_required": True,
"mfa_token": mfa_token,
"expires_in": auth.MFA_TOKEN_TTL_SEC,
"username": user.username,
}
auth.touch_last_login(user.username)
token, expires_in = auth.create_access_token(user)
return {
"access_token": token,
"token_type": "bearer",
"expires_in": expires_in,
"username": user.username,
"role": user.role,
"display_name": user.display_name,
}
@router.post("/login/mfa")
def login_mfa(body: LoginMfaRequest, request: Request):
client_ip = request.client.host if request.client else "unknown"
auth.check_login_rate_limit(client_ip)
username = auth.consume_mfa_token(body.mfa_token)
if not username:
raise HTTPException(401, "invalid or expired mfa session")
if body.backup_code:
with auth.db() as conn:
ok = backup_codes.consume_backup_code(conn, username, body.backup_code.strip())
conn.commit()
if not ok:
raise HTTPException(401, "código de backup inválido ou já utilizado")
elif not auth.verify_user_totp(username, body.totp_code or ""):
raise HTTPException(401, "invalid authenticator code")
row = auth._user_row(username)
if not row or not row["active"]:
raise HTTPException(401, "user inactive")
user = auth.DeskUser(
username=row["username"],
role=row["role"],
display_name=row["display_name"],
active=True,
)
auth.touch_last_login(user.username)
token, expires_in = auth.create_access_token(user)
return {
"access_token": token,
"token_type": "bearer",
"expires_in": expires_in,
"username": user.username,
"role": user.role,
"display_name": user.display_name,
}
@router.post("/logout")
def logout(user: auth.DeskUser = Depends(auth.get_current_user)):
return {"ok": True, "username": user.username}
@router.get("/me")
def me(user: auth.DeskUser = Depends(auth.get_current_user)):
with auth.db() as conn:
row = conn.execute(
"""
SELECT username, role, display_name, active, last_login_at, created_at, updated_at,
email, phone, mfa_enabled, totp_enabled
FROM desk_users WHERE username = ?
""",
(user.username,),
).fetchone()
if not row:
raise HTTPException(404, "user not found")
out = auth.user_public_dict(row)
with auth.db() as conn:
out["backup_codes_remaining"] = backup_codes.count_remaining(conn, user.username)
return out
@router.post("/change-password")
def change_password(
body: ChangePasswordRequest,
user: auth.DeskUser = Depends(auth.get_current_user),
):
row = auth._user_row(user.username)
if not row or not row["active"]:
raise HTTPException(401, "user inactive or not found")
if not auth.verify_password(body.current_password, row["password_hash"]):
raise HTTPException(401, "senha atual incorreta")
if body.current_password == body.new_password:
raise HTTPException(400, "a nova senha deve ser diferente da atual")
if auth.user_requires_totp(row):
code = (body.totp_code or "").strip()
if not code:
raise HTTPException(400, "código 2FA obrigatório")
if not auth.verify_user_totp(user.username, code):
raise HTTPException(401, "código 2FA inválido")
now = datetime.now(timezone.utc).isoformat()
with auth.db() as conn:
conn.execute(
"UPDATE desk_users SET password_hash = ?, updated_at = ? WHERE username = ?",
(auth.hash_password(body.new_password), now, user.username),
)
conn.commit()
return {"ok": True, "message": "Senha alterada com sucesso"}
@router.get("/users")
def list_users(user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
with auth.db() as conn:
rows = conn.execute(
"""
SELECT u.username, u.role, u.display_name, u.active, u.last_login_at,
u.created_at, u.updated_at, u.email, u.phone, u.mfa_enabled, u.totp_enabled,
(SELECT COUNT(*) FROM desk_backup_codes b
WHERE b.username = u.username AND b.used_at IS NULL) AS backup_codes_remaining
FROM desk_users u ORDER BY u.username
"""
).fetchall()
users = []
for row in rows:
item = auth.user_public_dict(row)
item["totp_enabled"] = bool(row["totp_enabled"])
item["backup_codes_remaining"] = int(row["backup_codes_remaining"] or 0)
users.append(item)
return {"users": users}
@router.patch("/users/{username}")
def update_user(
username: str,
body: UserUpdateRequest,
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
target = username.strip()
if target.lower() != "root":
target = target.lower()
updates: list[str] = []
params: list[object] = []
if body.role is not None:
if body.role not in ROLES:
raise HTTPException(400, "invalid role")
updates.append("role = ?")
params.append(body.role)
if body.active is not None:
updates.append("active = ?")
params.append(1 if body.active else 0)
if body.display_name is not None:
updates.append("display_name = ?")
params.append(body.display_name)
if body.password:
updates.append("password_hash = ?")
params.append(auth.hash_password(body.password))
if not updates:
raise HTTPException(400, "no fields to update")
now = datetime.now(timezone.utc).isoformat()
updates.append("updated_at = ?")
params.append(now)
params.append(target)
with auth.db() as conn:
cur = conn.execute(
f"UPDATE desk_users SET {', '.join(updates)} WHERE username = ?",
params,
)
conn.commit()
if cur.rowcount == 0:
raise HTTPException(404, "user not found")
row = conn.execute(
"""
SELECT u.username, u.role, u.display_name, u.active, u.last_login_at,
u.created_at, u.updated_at, u.email, u.phone, u.mfa_enabled, u.totp_enabled,
(SELECT COUNT(*) FROM desk_backup_codes b
WHERE b.username = u.username AND b.used_at IS NULL) AS backup_codes_remaining
FROM desk_users u WHERE u.username = ?
""",
(target,),
).fetchone()
item = auth.user_public_dict(row)
item["totp_enabled"] = bool(row["totp_enabled"])
item["backup_codes_remaining"] = int(row["backup_codes_remaining"] or 0)
return {"user": item}
@router.post("/users/{username}/reset-2fa")
def reset_user_2fa(
username: str,
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_manage_users(user.role):
raise HTTPException(403, "insufficient permissions")
target = username.strip()
if target.lower() != "root":
target = target.lower()
if target == "root":
raise HTTPException(400, "não é possível resetar 2FA do root por aqui")
now = datetime.now(timezone.utc).isoformat()
with auth.db() as conn:
row = conn.execute(
"SELECT username, email, totp_enabled FROM desk_users WHERE username = ?",
(target,),
).fetchone()
if not row:
raise HTTPException(404, "user not found")
if not row["totp_enabled"]:
raise HTTPException(400, "utilizador não tem 2FA ativo")
conn.execute(
"""
UPDATE desk_users
SET totp_secret = NULL, totp_enabled = 0, mfa_enabled = 0, updated_at = ?
WHERE username = ?
""",
(now, target),
)
conn.execute("DELETE FROM desk_backup_codes WHERE username = ?", (target,))
conn.commit()
email = row["email"] or target
mail_notify.notify_admin_2fa_reset(target, email, user.username)
return {"ok": True, "message": f"2FA resetado para {target}. O utilizador pode reconfigurar no login."}

107
api/app/backup_codes.py Normal file
View file

@ -0,0 +1,107 @@
"""Single-use backup codes for Desk 2FA (Spec 004 extension)."""
from __future__ import annotations
import hashlib
import secrets
import sqlite3
from datetime import datetime, timezone
BACKUP_CODE_COUNT = 10
_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
def _ensure_column(conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None:
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
if column not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}")
def init_backup_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS desk_backup_codes (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL,
code_hash TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_backup_codes_user ON desk_backup_codes(username)"
)
for col, ddl in [
("recovery_email_otp", "recovery_email_otp TEXT"),
("recovery_email_otp_expires", "recovery_email_otp_expires TEXT"),
]:
_ensure_column(conn, "desk_users", col, ddl)
def _normalize_code(code: str) -> str:
return code.strip().upper().replace(" ", "").replace("-", "")
def _format_code(raw: str) -> str:
return f"{raw[:4]}-{raw[4:]}"
def generate_backup_codes(count: int = BACKUP_CODE_COUNT) -> list[str]:
codes: list[str] = []
seen: set[str] = set()
while len(codes) < count:
raw = "".join(secrets.choice(_CHARS) for _ in range(8))
formatted = _format_code(raw)
if formatted not in seen:
seen.add(formatted)
codes.append(formatted)
return codes
def hash_backup_code(username: str, code: str) -> str:
norm = _normalize_code(code)
return hashlib.sha256(f"{username}:{norm}".encode()).hexdigest()
def store_backup_codes(conn: sqlite3.Connection, username: str, codes: list[str]) -> None:
now = datetime.now(timezone.utc).isoformat()
conn.execute("DELETE FROM desk_backup_codes WHERE username = ?", (username,))
for code in codes:
conn.execute(
"""
INSERT INTO desk_backup_codes (username, code_hash, created_at)
VALUES (?, ?, ?)
""",
(username, hash_backup_code(username, code), now),
)
def consume_backup_code(conn: sqlite3.Connection, username: str, code: str) -> bool:
h = hash_backup_code(username, code)
row = conn.execute(
"""
SELECT id FROM desk_backup_codes
WHERE username = ? AND code_hash = ? AND used_at IS NULL
""",
(username, h),
).fetchone()
if not row:
return False
now = datetime.now(timezone.utc).isoformat()
conn.execute(
"UPDATE desk_backup_codes SET used_at = ? WHERE id = ?",
(now, row["id"]),
)
return True
def count_remaining(conn: sqlite3.Connection, username: str) -> int:
row = conn.execute(
"""
SELECT COUNT(*) c FROM desk_backup_codes
WHERE username = ? AND used_at IS NULL
""",
(username,),
).fetchone()
return int(row["c"]) if row else 0

115
api/app/billing_routes.py Normal file
View file

@ -0,0 +1,115 @@
"""Billing API routes — Spec 023."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from app import auth, billing_store
from app.permissions import can_manage_billing, can_read_billing, should_mask_sensitive
router = APIRouter(prefix="/api/v1/billing", tags=["billing"])
class PatchBillingBody(BaseModel):
billing_state: str | None = None
recurrence_active: bool | None = None
external_customer_id: str | None = None
plan_code: str | None = None
activated_by: str | None = None
def _reader(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
if not can_read_billing(user.role):
raise HTTPException(403, "permissão insuficiente")
return user
def _manager(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
if not can_manage_billing(user.role):
raise HTTPException(403, "permissão insuficiente")
return user
@router.get("/summary")
def billing_summary(user: auth.DeskUser = Depends(_reader)):
conn = auth.db()
try:
data = billing_store.summary(conn)
if should_mask_sensitive(user.role):
data["recent_validations"] = [
billing_store._row_dict(r, mask=True)
for r in conn.execute(
"SELECT * FROM billing_accounts ORDER BY updated_at DESC LIMIT 5"
).fetchall()
]
return data
finally:
conn.close()
@router.get("/accounts")
def list_billing_accounts(
billing_state: str = "",
domain: str = "",
limit: int = Query(100, ge=1, le=500),
user: auth.DeskUser = Depends(_reader),
):
conn = auth.db()
try:
mask = should_mask_sensitive(user.role)
return billing_store.list_accounts(
conn,
billing_state=billing_state.strip() or None,
domain=domain.strip() or None,
limit=limit,
mask=mask,
)
finally:
conn.close()
@router.get("/accounts/by-domain/{domain}")
def billing_by_domain(domain: str, user: auth.DeskUser = Depends(_reader)):
conn = auth.db()
try:
acc = billing_store.get_by_domain(conn, domain, mask=should_mask_sensitive(user.role))
finally:
conn.close()
if not acc:
raise HTTPException(404, "conta não encontrada")
return acc
@router.get("/accounts/{account_id}")
def get_billing_account(account_id: int, user: auth.DeskUser = Depends(_reader)):
conn = auth.db()
try:
acc = billing_store.get_account(conn, account_id, mask=should_mask_sensitive(user.role))
finally:
conn.close()
if not acc:
raise HTTPException(404, "conta não encontrada")
return acc
@router.patch("/accounts/{account_id}")
def patch_billing_account(
account_id: int,
body: PatchBillingBody,
user: auth.DeskUser = Depends(_manager),
):
conn = auth.db()
try:
fields = body.model_dump(exclude_none=True)
if body.recurrence_active and not fields.get("activated_by"):
from datetime import datetime, timezone
fields["activated_by"] = user.username
fields["activated_at"] = datetime.now(timezone.utc).isoformat()
acc = billing_store.patch_account(conn, account_id, **fields)
finally:
conn.close()
if not acc:
raise HTTPException(404, "conta não encontrada")
return acc

272
api/app/billing_store.py Normal file
View file

@ -0,0 +1,272 @@
"""Billing accounts store — Spec 023."""
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from typing import Any
FOSSBILLING_URL = "https://financeiro.ligbox.com.br"
ODOO_URL = "https://financeiro.ligbox.com.br/odoo/web/login?db=ligbox"
TAX_ID_RE = re.compile(r"\d")
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def init_schema(conn) -> None:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS billing_accounts (
id INTEGER PRIMARY KEY,
domain TEXT NOT NULL,
session_id TEXT,
ticket_id INTEGER,
tax_id TEXT,
legal_name TEXT,
trade_name TEXT,
email_billing TEXT,
company_profile_json TEXT,
billing_state TEXT NOT NULL DEFAULT 'awaiting_billing_validation',
recurrence_active INTEGER NOT NULL DEFAULT 0,
external_customer_id TEXT,
external_subscription_id TEXT,
payment_provider TEXT,
plan_code TEXT,
activated_at TEXT,
activated_by TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_billing_domain ON billing_accounts(domain);
"""
)
def _mask_tax_id(tax_id: str | None) -> str:
if not tax_id:
return ""
digits = TAX_ID_RE.sub("", tax_id)
if len(digits) < 4:
return "***"
return f"{'*' * (len(digits) - 4)}{digits[-4:]}"
def _mask_email(email: str | None) -> str:
if not email or "@" not in email:
return ""
local, dom = email.split("@", 1)
if len(local) <= 2:
return f"**@{dom}"
return f"{local[:2]}***@{dom}"
def _row_dict(row, *, mask: bool = False) -> dict[str, Any]:
profile = {}
if row["company_profile_json"]:
try:
profile = json.loads(row["company_profile_json"])
except json.JSONDecodeError:
profile = {}
out = {
"id": row["id"],
"domain": row["domain"],
"session_id": row["session_id"],
"ticket_id": row["ticket_id"],
"tax_id": _mask_tax_id(row["tax_id"]) if mask else row["tax_id"],
"legal_name": row["legal_name"],
"trade_name": row["trade_name"],
"email_billing": _mask_email(row["email_billing"]) if mask else row["email_billing"],
"company_profile": profile if not mask else _mask_profile(profile),
"billing_state": row["billing_state"],
"recurrence_active": bool(row["recurrence_active"]),
"external_customer_id": row["external_customer_id"],
"external_subscription_id": row["external_subscription_id"],
"payment_provider": row["payment_provider"],
"plan_code": row["plan_code"],
"activated_at": row["activated_at"],
"activated_by": row["activated_by"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"links": {
"fossbilling": FOSSBILLING_URL,
"odoo": ODOO_URL,
},
}
return out
def _mask_profile(profile: dict) -> dict:
p = dict(profile)
if p.get("tax_id"):
p["tax_id"] = _mask_tax_id(str(p["tax_id"]))
if p.get("email_billing"):
p["email_billing"] = _mask_email(str(p["email_billing"]))
return p
def upsert_from_company_validated(
conn,
*,
domain: str,
session_id: str | None,
ticket_id: int | None,
data: dict | None,
) -> dict[str, Any]:
dom = domain.strip().lower()
profile = (data or {}).get("company_profile") or {}
billing_state = (data or {}).get("billing_state") or "awaiting_billing_validation"
now = _now()
existing = conn.execute(
"SELECT id FROM billing_accounts WHERE domain = ?",
(dom,),
).fetchone()
if existing:
conn.execute(
"""
UPDATE billing_accounts SET
session_id = COALESCE(?, session_id),
ticket_id = COALESCE(?, ticket_id),
tax_id = ?, legal_name = ?, trade_name = ?, email_billing = ?,
company_profile_json = ?, billing_state = ?, updated_at = ?
WHERE domain = ?
""",
(
session_id,
ticket_id,
profile.get("tax_id"),
profile.get("legal_name"),
profile.get("trade_name"),
profile.get("email_billing"),
json.dumps(profile, ensure_ascii=False),
billing_state,
now,
dom,
),
)
acc_id = int(existing["id"])
else:
cur = conn.execute(
"""
INSERT INTO billing_accounts
(domain, session_id, ticket_id, tax_id, legal_name, trade_name, email_billing,
company_profile_json, billing_state, recurrence_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
""",
(
dom,
session_id,
ticket_id,
profile.get("tax_id"),
profile.get("legal_name"),
profile.get("trade_name"),
profile.get("email_billing"),
json.dumps(profile, ensure_ascii=False),
billing_state,
now,
now,
),
)
acc_id = int(cur.lastrowid)
conn.commit()
return get_account(conn, acc_id) or {}
def get_account(conn, account_id: int, *, mask: bool = False) -> dict[str, Any] | None:
row = conn.execute("SELECT * FROM billing_accounts WHERE id = ?", (account_id,)).fetchone()
return _row_dict(row, mask=mask) if row else None
def get_by_domain(conn, domain: str, *, mask: bool = False) -> dict[str, Any] | None:
row = conn.execute(
"SELECT * FROM billing_accounts WHERE domain = ?",
(domain.strip().lower(),),
).fetchone()
return _row_dict(row, mask=mask) if row else None
def list_accounts(
conn,
*,
billing_state: str | None = None,
domain: str | None = None,
limit: int = 100,
mask: bool = False,
) -> dict[str, Any]:
limit = max(1, min(limit, 500))
clauses = []
params: list[Any] = []
if billing_state:
clauses.append("billing_state = ?")
params.append(billing_state)
if domain:
clauses.append("domain LIKE ?")
params.append(f"%{domain.strip().lower()}%")
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
rows = conn.execute(
f"SELECT * FROM billing_accounts {where} ORDER BY updated_at DESC LIMIT ?",
(*params, limit),
).fetchall()
total = conn.execute(
f"SELECT COUNT(*) FROM billing_accounts {where}",
tuple(params),
).fetchone()[0]
return {
"accounts": [_row_dict(r, mask=mask) for r in rows],
"total": total,
}
def patch_account(conn, account_id: int, **fields) -> dict[str, Any] | None:
allowed = {
"billing_state",
"recurrence_active",
"external_customer_id",
"external_subscription_id",
"payment_provider",
"plan_code",
"activated_at",
"activated_by",
"ticket_id",
}
if fields.get("recurrence_active"):
fields.setdefault("billing_state", "billing_active")
sets = []
params: list[Any] = []
for key, val in fields.items():
if key not in allowed:
continue
if key == "recurrence_active":
val = 1 if val else 0
sets.append(f"{key} = ?")
params.append(val)
if not sets:
return get_account(conn, account_id)
sets.append("updated_at = ?")
params.append(_now())
params.append(account_id)
conn.execute(f"UPDATE billing_accounts SET {', '.join(sets)} WHERE id = ?", params)
conn.commit()
return get_account(conn, account_id)
def summary(conn) -> dict[str, Any]:
pending = conn.execute(
"SELECT COUNT(*) FROM billing_accounts WHERE billing_state = 'awaiting_billing_validation'"
).fetchone()[0]
active = conn.execute(
"SELECT COUNT(*) FROM billing_accounts WHERE recurrence_active = 1"
).fetchone()[0]
total = conn.execute("SELECT COUNT(*) FROM billing_accounts").fetchone()[0]
recent = conn.execute(
"SELECT * FROM billing_accounts ORDER BY updated_at DESC LIMIT 5"
).fetchall()
return {
"billing_pending": pending,
"billing_active": active,
"billing_total": total,
"recent_validations": [_row_dict(r) for r in recent],
}

View file

@ -0,0 +1,120 @@
"""Rotas libertação ACCOUNT_EXISTS — Spec 022."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from app import auth, carbonio_release_store, vm112_domains
from app.permissions import can_read_tickets
router = APIRouter(prefix="/api/v1/carbonio-blocks", tags=["carbonio-release"])
class ResolveBlockBody(BaseModel):
confirm_email: str = Field(..., min_length=5)
password: str = Field(..., min_length=1)
def _require_ticket_reader(user: auth.DeskUser = Depends(auth.get_current_user)) -> auth.DeskUser:
if not can_read_tickets(user.role):
raise HTTPException(403, "permissão insuficiente")
return user
def _verify_user_password(conn, username: str, password: str) -> bool:
row = conn.execute(
"SELECT password_hash FROM desk_users WHERE username = ? AND active = 1",
(username,),
).fetchone()
if not row or not row["password_hash"]:
return False
return auth.verify_password(password, row["password_hash"])
@router.get("")
def list_carbonio_blocks(
status: str = Query("pending"),
session_id: str = "",
ticket_id: int | None = None,
limit: int = Query(100, ge=1, le=500),
user: auth.DeskUser = Depends(_require_ticket_reader),
):
conn = auth.db()
try:
return carbonio_release_store.list_blocks(
conn,
status=status or "all",
session_id=session_id.strip() or None,
ticket_id=ticket_id,
limit=limit,
)
finally:
conn.close()
@router.get("/{block_id}")
def get_carbonio_block(
block_id: int,
user: auth.DeskUser = Depends(_require_ticket_reader),
):
conn = auth.db()
try:
block = carbonio_release_store.get_block(conn, block_id)
finally:
conn.close()
if not block:
raise HTTPException(404, "bloqueio não encontrado")
return block
@router.post("/{block_id}/resolve")
def resolve_carbonio_block(
block_id: int,
body: ResolveBlockBody,
user: auth.DeskUser = Depends(_require_ticket_reader),
):
conn = auth.db()
try:
block = carbonio_release_store.get_block(conn, block_id)
if not block:
raise HTTPException(404, "bloqueio não encontrado")
if block["status"] == "resolved":
raise HTTPException(409, f"Já resolvido por {block.get('resolved_by') or 'outro técnico'}")
if body.confirm_email.lower().strip() != block["email"].lower():
raise HTTPException(400, "E-mail de confirmação não coincide")
if not _verify_user_password(conn, user.username, body.password):
raise HTTPException(403, "Senha incorrecta")
try:
vm_result = vm112_domains.delete_carbonio_account(block["email"])
except Exception as e:
raise HTTPException(502, f"VM112/Carbonio: {e}") from e
try:
resolved = carbonio_release_store.resolve_block(
conn,
block_id,
resolved_by=user.username,
note=vm_result.get("message", "zmprov da"),
)
except ValueError as e:
if str(e) == "already_resolved":
raise HTTPException(409, "Outro técnico já resolveu este bloqueio") from e
raise HTTPException(404, "Bloqueio indisponível") from e
if block.get("ticket_id"):
carbonio_release_store.append_ticket_resolution_note(
conn,
int(block["ticket_id"]),
email=block["email"],
by=user.username,
)
return {
"ok": True,
"block": resolved,
"vm112": vm_result,
"message": f"Conta {block['email']} removida do Carbonio. Peça ao cliente para repetir «Criar conta» no wizard.",
}
finally:
conn.close()

View file

@ -0,0 +1,239 @@
"""Bloqueios ACCOUNT_EXISTS — libertar e-mail Carbonio (Spec 022)."""
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from typing import Any
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def init_schema(conn) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS carbonio_account_blocks (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL,
domain TEXT NOT NULL,
session_id TEXT,
ticket_id INTEGER,
webhook_event_id INTEGER,
error_message TEXT,
status TEXT NOT NULL DEFAULT 'pending',
resolved_by TEXT,
resolved_at TEXT,
resolution_note TEXT,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_carbonio_blocks_status
ON carbonio_account_blocks(status, created_at DESC)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_carbonio_blocks_session
ON carbonio_account_blocks(session_id)
"""
)
def is_account_exists_failure(event: str, data: dict | None) -> bool:
if event != "onboarding.failed":
return False
if not isinstance(data, dict):
return False
err = str(data.get("error") or data.get("message") or data.get("reason") or "")
low = err.lower()
return (
"account_exists" in low
or "account.exists" in low
or "já existe" in low
or "already exists" in low
)
def _extract_email(domain: str | None, data: dict | None) -> str | None:
if isinstance(data, dict):
for key in ("email", "account", "mailbox"):
val = (data.get(key) or "").strip().lower()
if EMAIL_RE.match(val):
return val
err = str(data.get("error") or "")
m = re.search(r"[\w.+-]+@[\w.-]+\.\w+", err)
if m:
return m.group(0).lower()
if domain and isinstance(data, dict):
local = (data.get("local_part") or "").strip().lower()
if local:
return f"{local}@{domain.lower().strip()}"
return None
def upsert_from_webhook(
conn,
*,
event: str,
domain: str | None,
session_id: str | None,
data: dict | None,
webhook_event_id: int | None,
ticket_id: int | None,
) -> dict[str, Any] | None:
if not is_account_exists_failure(event, data):
return None
email = _extract_email(domain, data)
if not email:
return None
dom = (domain or email.split("@", 1)[-1]).lower().strip()
sid = (session_id or "").strip() or None
err_msg = str((data or {}).get("error") or "")[:2000]
now = _now()
if sid:
row = conn.execute(
"""
SELECT id, status FROM carbonio_account_blocks
WHERE email = ? AND session_id = ? AND status = 'pending'
ORDER BY id DESC LIMIT 1
""",
(email, sid),
).fetchone()
if row:
conn.execute(
"""
UPDATE carbonio_account_blocks
SET error_message = ?, webhook_event_id = COALESCE(?, webhook_event_id),
ticket_id = COALESCE(?, ticket_id)
WHERE id = ?
""",
(err_msg, webhook_event_id, ticket_id, row["id"]),
)
conn.commit()
return get_block(conn, int(row["id"]))
cur = conn.execute(
"""
INSERT INTO carbonio_account_blocks
(email, domain, session_id, ticket_id, webhook_event_id, error_message, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)
""",
(email, dom, sid, ticket_id, webhook_event_id, err_msg, now),
)
conn.commit()
return get_block(conn, int(cur.lastrowid))
def get_block(conn, block_id: int) -> dict[str, Any] | None:
row = conn.execute(
"SELECT * FROM carbonio_account_blocks WHERE id = ?",
(block_id,),
).fetchone()
return _row_to_dict(row) if row else None
def _row_to_dict(row) -> dict[str, Any]:
return {
"id": row["id"],
"email": row["email"],
"domain": row["domain"],
"session_id": row["session_id"],
"ticket_id": row["ticket_id"],
"webhook_event_id": row["webhook_event_id"],
"error_message": row["error_message"],
"status": row["status"],
"resolved_by": row["resolved_by"],
"resolved_at": row["resolved_at"],
"resolution_note": row["resolution_note"],
"created_at": row["created_at"],
}
def list_blocks(
conn,
*,
status: str | None = "pending",
session_id: str | None = None,
ticket_id: int | None = None,
limit: int = 100,
) -> dict[str, Any]:
limit = max(1, min(limit, 500))
clauses = []
params: list[Any] = []
if status and status != "all":
clauses.append("status = ?")
params.append(status)
if session_id:
clauses.append("session_id = ?")
params.append(session_id.strip())
if ticket_id:
clauses.append("ticket_id = ?")
params.append(ticket_id)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
rows = conn.execute(
f"""
SELECT * FROM carbonio_account_blocks {where}
ORDER BY id DESC LIMIT ?
""",
(*params, limit),
).fetchall()
total = conn.execute(
f"SELECT COUNT(*) FROM carbonio_account_blocks {where}",
tuple(params),
).fetchone()[0]
return {
"blocks": [_row_to_dict(r) for r in rows],
"total": total,
}
def resolve_block(
conn,
block_id: int,
*,
resolved_by: str,
note: str = "",
) -> dict[str, Any]:
now = _now()
cur = conn.execute(
"""
UPDATE carbonio_account_blocks
SET status = 'resolved', resolved_by = ?, resolved_at = ?, resolution_note = ?
WHERE id = ? AND status = 'pending'
""",
(resolved_by, now, note[:500], block_id),
)
if cur.rowcount == 0:
existing = get_block(conn, block_id)
if existing and existing["status"] == "resolved":
raise ValueError("already_resolved")
raise ValueError("not_found_or_locked")
conn.commit()
return get_block(conn, block_id) or {}
def append_ticket_resolution_note(conn, ticket_id: int, *, email: str, by: str) -> None:
row = conn.execute("SELECT payload FROM tickets WHERE id = ?", (ticket_id,)).fetchone()
if not row:
return
try:
payload = json.loads(row["payload"] or "{}")
except json.JSONDecodeError:
payload = {}
notes = payload.get("carbonio_release_notes") or []
notes.append({"at": _now(), "email": email, "by": by, "action": "account_deleted"})
payload["carbonio_release_notes"] = notes[-20:]
conn.execute(
"UPDATE tickets SET payload = ? WHERE id = ?",
(json.dumps(payload, ensure_ascii=False), ticket_id),
)
conn.commit()

181
api/app/cloudflare_dns.py Normal file
View file

@ -0,0 +1,181 @@
"""Cloudflare DNS records for domain management (read-only)."""
from __future__ import annotations
import os
from typing import Any
import httpx
CF_API = "https://api.cloudflare.com/client/v4"
EMAIL_PURPOSES = frozenset({"mx", "spf", "dkim", "dmarc", "mail-host", "autodiscover", "mail-alias"})
def _tokens() -> list[str]:
raw = os.getenv("CLOUDFLARE_API_TOKENS") or os.getenv("CLOUDFLARE_API_TOKEN") or ""
return [t.strip() for t in raw.replace(";", ",").split(",") if t.strip()]
def _parent_candidates(domain: str) -> list[str]:
domain = domain.lower().strip().rstrip(".")
parts = domain.split(".")
if len(parts) < 2:
return [domain] if domain else []
return [".".join(parts[i:]) for i in range(len(parts) - 1)]
def _headers(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
def _classify_record(name: str, rtype: str, content: str) -> str:
n = (name or "").lower().rstrip(".")
c = (content or "").lower()
if rtype == "MX":
return "mx"
if rtype == "TXT":
if "v=spf1" in c:
return "spf"
if "_domainkey" in n or "v=dkim1" in c:
return "dkim"
if "_dmarc" in n or "v=dmarc1" in c:
return "dmarc"
if n.startswith("mail.") and rtype in ("A", "AAAA", "CNAME"):
return "mail-host"
if "autodiscover" in n or "autoconfig" in n:
return "autodiscover"
if rtype == "CNAME" and ("mail" in n or "autodiscover" in n):
return "mail-alias"
return "other"
def _record_belongs(name: str, domain: str) -> bool:
rn = (name or "").lower().rstrip(".")
d = domain.lower().strip().rstrip(".")
return rn == d or rn.endswith(f".{d}")
def _normalize_record(raw: dict, domain: str) -> dict[str, Any]:
name = raw.get("name", "")
rtype = raw.get("type", "")
content = raw.get("content", "")
purpose = _classify_record(name, rtype, content)
return {
"id": raw.get("id"),
"type": rtype,
"name": name.rstrip("."),
"content": content,
"priority": raw.get("priority"),
"proxied": raw.get("proxied"),
"ttl": raw.get("ttl"),
"purpose": purpose,
"email_related": purpose in EMAIL_PURPOSES,
"modified_on": raw.get("modified_on"),
"created_on": raw.get("created_on"),
}
async def _find_zone(client: httpx.AsyncClient, token: str, domain: str) -> dict | None:
for candidate in _parent_candidates(domain):
res = await client.get(
f"{CF_API}/zones",
headers=_headers(token),
params={"name": candidate, "status": "active"},
)
if res.status_code != 200:
continue
data = res.json()
if not data.get("success"):
continue
zones = data.get("result") or []
if zones:
z = zones[0]
return {"id": z.get("id"), "name": z.get("name"), "status": z.get("status")}
return None
async def _list_zone_records(client: httpx.AsyncClient, token: str, zone_id: str) -> list[dict]:
records: list[dict] = []
page = 1
while page <= 10:
res = await client.get(
f"{CF_API}/zones/{zone_id}/dns_records",
headers=_headers(token),
params={"per_page": 100, "page": page},
)
if res.status_code != 200:
break
data = res.json()
if not data.get("success"):
break
batch = data.get("result") or []
records.extend(batch)
info = data.get("result_info") or {}
if page >= (info.get("total_pages") or 1):
break
page += 1
return records
async def fetch_domain_dns(domain: str, *, email_service: bool | None = None) -> dict[str, Any]:
domain = domain.lower().strip().rstrip(".")
tokens = _tokens()
if not tokens:
return {
"domain": domain,
"zone": None,
"email_service": bool(email_service),
"service_type": "email_server" if email_service else None,
"records": [],
"email_records": [],
"summary": {"total": 0, "email_related": 0},
"error": "CLOUDFLARE_API_TOKEN não configurado no servidor",
}
async with httpx.AsyncClient(timeout=20.0) as client:
zone = None
token_used = None
for token in tokens:
zone = await _find_zone(client, token, domain)
if zone:
token_used = token
break
if not zone or not token_used:
return {
"domain": domain,
"zone": None,
"email_service": bool(email_service),
"service_type": "email_server" if email_service else None,
"records": [],
"email_records": [],
"summary": {"total": 0, "email_related": 0},
"error": f"Zona Cloudflare não encontrada para {domain}",
}
raw_records = await _list_zone_records(client, token_used, zone["id"])
scoped = [_normalize_record(r, domain) for r in raw_records if _record_belongs(r.get("name", ""), domain)]
scoped.sort(key=lambda r: (0 if r["email_related"] else 1, r["type"], r["name"]))
email_records = [r for r in scoped if r["email_related"]]
is_email = email_service if email_service is not None else len(email_records) > 0
purposes: dict[str, int] = {}
for r in scoped:
purposes[r["purpose"]] = purposes.get(r["purpose"], 0) + 1
return {
"domain": domain,
"zone": zone,
"email_service": is_email,
"service_type": "email_server" if is_email else "other",
"records": scoped,
"email_records": email_records,
"summary": {
"total": len(scoped),
"email_related": len(email_records),
"by_purpose": purposes,
},
"error": None,
}

View file

@ -0,0 +1,3 @@
from .base import run_audit
__all__ = ["run_audit"]

View file

@ -0,0 +1,55 @@
"""Run all read-only audit checks for a tenant domain."""
from __future__ import annotations
from typing import Any
from . import dns, vm112, webmail
CHECK_LABELS = {
"carbonio": "Carbonio domain",
"nginx_vhost": "carbonio-nginx vhost",
"cert_le": "Let's Encrypt certificate",
"dns_mx": "MX record",
"dns_spf": "SPF",
"dns_dkim": "DKIM",
"dns_dmarc": "DMARC",
"webmail_http": "Webmail HTTPS",
}
TENANT_API_BASE = {
1: None, # filled from env in run_audit
}
def run_audit(
tenant_id: int,
domain: str,
*,
vm112_api: str | None = None,
mail_public_ip: str | None = None,
) -> dict[str, dict[str, Any]]:
domain = domain.lower().strip()
results: dict[str, dict[str, Any]] = {}
if tenant_id == 1:
api_base = vm112_api or "http://10.10.10.112:8090"
results.update(vm112.collect(domain, api_base))
results.update(dns.collect(domain, mail_public_ip=mail_public_ip))
results.update(webmail.collect(domain))
for check_id, label in CHECK_LABELS.items():
results.setdefault(
check_id,
{
"check_id": check_id,
"label": label,
"status": "skip",
"message": "Check not run",
"evidence": {},
},
)
results[check_id]["label"] = label
return results

86
api/app/collectors/dns.py Normal file
View file

@ -0,0 +1,86 @@
"""Public DNS checks via dig (read-only)."""
from __future__ import annotations
import subprocess
from typing import Any
def _dig(*args: str) -> list[str]:
try:
proc = subprocess.run(
["dig", "+short", *args],
capture_output=True,
text=True,
timeout=8,
)
if proc.returncode != 0:
return []
lines = [ln.strip().strip('"') for ln in proc.stdout.splitlines() if ln.strip()]
return lines
except Exception:
return []
def _result(check_id: str, label: str, status: str, message: str, evidence: dict | None = None) -> dict[str, Any]:
return {
"check_id": check_id,
"label": label,
"status": status,
"message": message,
"evidence": evidence or {},
}
def collect(domain: str, mail_public_ip: str | None = None) -> dict[str, dict[str, Any]]:
domain = domain.lower().strip()
mail_host = f"mail.{domain}"
results: dict[str, dict[str, Any]] = {}
mx = _dig(domain, "MX")
mx_ok = any(mail_host in line or domain in line for line in mx)
results["dns_mx"] = _result(
"dns_mx",
"MX record",
"pass" if mx_ok else "fail",
f"MX: {', '.join(mx[:3]) or 'none'}",
{"records": mx},
)
txt_root = _dig(domain, "TXT")
spf = [t for t in txt_root if t.lower().startswith("v=spf1")]
results["dns_spf"] = _result(
"dns_spf",
"SPF",
"pass" if spf else "fail",
spf[0][:120] if spf else "SPF TXT not found",
{"records": spf},
)
dkim_name = f"default._domainkey.{domain}"
dkim = _dig(dkim_name, "TXT")
results["dns_dkim"] = _result(
"dns_dkim",
"DKIM",
"pass" if dkim else "fail",
"DKIM TXT present" if dkim else f"{dkim_name} not found",
{"records": dkim[:2]},
)
dmarc_name = f"_dmarc.{domain}"
dmarc = _dig(dmarc_name, "TXT")
results["dns_dmarc"] = _result(
"dns_dmarc",
"DMARC",
"pass" if dmarc else "warn",
dmarc[0][:120] if dmarc else "DMARC TXT not found",
{"records": dmarc},
)
if mail_public_ip:
a_mail = _dig(mail_host, "A")
if mail_public_ip not in a_mail and results["dns_mx"]["status"] == "pass":
results["dns_mx"]["status"] = "warn"
results["dns_mx"]["message"] += f" (A {mail_host}: {a_mail or 'none'})"
return results

View file

@ -0,0 +1,67 @@
"""VM112 portal infrastructure checks (read-only API)."""
from __future__ import annotations
from typing import Any
import httpx
def _result(check_id: str, label: str, ok: bool | None, message: str, evidence: dict | None = None) -> dict[str, Any]:
if ok is True:
status = "pass"
elif ok is False:
status = "fail"
else:
status = "error"
return {
"check_id": check_id,
"label": label,
"status": status,
"message": message,
"evidence": evidence or {},
}
def collect(domain: str, api_base: str) -> dict[str, dict[str, Any]]:
url = f"{api_base.rstrip('/')}/api/onboarding/infrastructure/status/{domain}"
try:
with httpx.Client(timeout=15.0) as client:
response = client.get(url)
if response.status_code != 200:
err = _result("carbonio", "Carbonio domain", None, f"Portal API HTTP {response.status_code}")
return {
"carbonio": err,
"nginx_vhost": {**err, "check_id": "nginx_vhost", "label": "carbonio-nginx vhost"},
"cert_le": {**err, "check_id": "cert_le", "label": "Let's Encrypt certificate"},
}
data = response.json()
except Exception as exc:
err = _result("carbonio", "Carbonio domain", None, str(exc))
return {
"carbonio": err,
"nginx_vhost": {**err, "check_id": "nginx_vhost", "label": "carbonio-nginx vhost"},
"cert_le": {**err, "check_id": "cert_le", "label": "Let's Encrypt certificate"},
}
steps = {s.get("id"): s for s in data.get("steps") or [] if isinstance(s, dict)}
def from_step(check_id: str, label: str, step_id: str) -> dict[str, Any]:
step = steps.get(step_id) or {}
return _result(
check_id,
label,
step.get("ok"),
step.get("message") or f"Step {step_id}",
{"step_id": step_id, "ready": data.get("ready")},
)
cert = from_step("cert_le", "Let's Encrypt certificate", "cert_san")
if cert["status"] == "pass":
cert["status"] = "pass"
return {
"carbonio": from_step("carbonio", "Carbonio domain", "carbonio_domain"),
"nginx_vhost": from_step("nginx_vhost", "carbonio-nginx vhost", "carbonio_nginx_vhost"),
"cert_le": cert,
}

View file

@ -0,0 +1,41 @@
"""Webmail HTTPS check (read-only)."""
from __future__ import annotations
from typing import Any
import httpx
def collect(domain: str) -> dict[str, dict[str, Any]]:
domain = domain.lower().strip()
url = f"https://mail.{domain}/"
try:
with httpx.Client(timeout=12.0, follow_redirects=True, verify=True) as client:
response = client.get(url)
code = response.status_code
if 200 <= code < 400:
status, message = "pass", f"HTTP {code}"
elif code == 403:
status, message = "warn", f"HTTP {code}"
else:
status, message = "fail", f"HTTP {code}"
return {
"webmail_http": {
"check_id": "webmail_http",
"label": "Webmail HTTPS",
"status": status,
"message": message,
"evidence": {"url": url, "status_code": code},
}
}
except Exception as exc:
return {
"webmail_http": {
"check_id": "webmail_http",
"label": "Webmail HTTPS",
"status": "fail",
"message": str(exc)[:200],
"evidence": {"url": url},
}
}

199
api/app/crm_leads.py Normal file
View file

@ -0,0 +1,199 @@
"""Abandoned onboarding → Lead CRM — Spec 012 Phase B."""
from __future__ import annotations
import json
import os
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Any
ONBOARD_STALE_HOURS = int(os.getenv("ONBOARD_STALE_HOURS", "24"))
ONBOARD_SOURCE = "vm112-onboard"
FUNNEL_EVENT_RANK = {
"onboarding.started": 1,
"domain.validated": 2,
"dns.applied": 3,
"account.created": 4,
"infra.synced": 5,
"onboarding.completed": 6,
"onboarding.failed": 99,
}
FUNNEL_STAGE_BY_RANK = {
1: "started",
2: "domain_validated",
3: "dns_applied",
4: "account_created",
5: "infra_synced",
6: "completed",
99: "failed",
}
LEAD_PROMOTE_STATUSES = frozenset({"open", "escalated"})
SKIP_STAGES = frozenset({"completed", "failed"})
def _parse_payload(raw: str | None) -> dict:
if not raw:
return {}
try:
return json.loads(raw)
except json.JSONDecodeError:
return {}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def session_funnel_state(conn: sqlite3.Connection, session_id: str) -> dict[str, Any]:
sid = (session_id or "").strip()
if not sid:
return {"stage": "unknown", "last_event_at": None, "failed": False}
rows = conn.execute(
"""
SELECT event_type, payload, created_at
FROM webhook_events
WHERE source = ?
ORDER BY id ASC
""",
(ONBOARD_SOURCE,),
).fetchall()
max_rank = 0
last_event_at = None
domain = None
failed = False
for row in rows:
payload = _parse_payload(row["payload"])
if (payload.get("session_id") or "").strip() != sid:
continue
if payload.get("domain"):
domain = payload.get("domain")
last_event_at = row["created_at"]
if row["event_type"] == "onboarding.failed":
failed = True
max_rank = max(max_rank, 99)
else:
rank = FUNNEL_EVENT_RANK.get(row["event_type"], 0)
if rank > max_rank and not failed:
max_rank = rank
if failed:
stage = "failed"
else:
stage = FUNNEL_STAGE_BY_RANK.get(max_rank, "started")
return {
"stage": stage,
"last_event_at": last_event_at,
"failed": failed,
"domain": domain,
}
def is_session_stale(last_event_at: str | None, stage: str, stale_hours: int) -> bool:
if not last_event_at or stage in SKIP_STAGES:
return False
cutoff = (datetime.now(timezone.utc) - timedelta(hours=stale_hours)).isoformat()
return last_event_at < cutoff
def promote_stale_leads(conn: sqlite3.Connection, stale_hours: int | None = None) -> dict[str, Any]:
hours = stale_hours if stale_hours is not None else ONBOARD_STALE_HOURS
now = _now()
promoted_ids: list[int] = []
rows = conn.execute(
"""
SELECT id, status, session_id, subject, payload, created_at
FROM tickets
WHERE session_id IS NOT NULL AND session_id != ''
ORDER BY id DESC
LIMIT 500
"""
).fetchall()
for row in rows:
payload = _parse_payload(row["payload"])
crm_track = payload.get("crm_track") or "onboarding"
if crm_track != "onboarding":
continue
if row["status"] not in LEAD_PROMOTE_STATUSES:
continue
sid = (row["session_id"] or payload.get("session_id") or "").strip()
if not sid:
continue
meta = session_funnel_state(conn, sid)
if not is_session_stale(meta.get("last_event_at"), meta.get("stage", ""), hours):
continue
payload["crm_track"] = "lead"
payload["lead_detected_at"] = now
payload["lead_reason"] = "stale_session"
payload["lead_funnel_stage"] = meta.get("stage")
payload["lead_last_event_at"] = meta.get("last_event_at")
conn.execute(
"UPDATE tickets SET payload = ? WHERE id = ?",
(json.dumps(payload), int(row["id"])),
)
promoted_ids.append(int(row["id"]))
if promoted_ids:
conn.commit()
return {
"promoted": len(promoted_ids),
"ticket_ids": promoted_ids,
"stale_hours": hours,
"ran_at": now,
}
def lead_from_ticket(row: sqlite3.Row, conn: sqlite3.Connection | None = None) -> dict[str, Any]:
payload = _parse_payload(row["payload"])
sid = (row["session_id"] or payload.get("session_id") or "").strip()
stage = payload.get("lead_funnel_stage")
last_event = payload.get("lead_last_event_at")
domain = payload.get("domain")
if conn and sid and (not stage or not last_event):
meta = session_funnel_state(conn, sid)
stage = stage or meta.get("stage")
last_event = last_event or meta.get("last_event_at")
domain = domain or meta.get("domain")
email = payload.get("account_email") or (payload.get("data") or {}).get("email")
return {
"ticket_id": int(row["id"]),
"session_id": sid,
"domain": domain,
"email": email,
"subject": row["subject"],
"status": row["status"],
"crm_track": payload.get("crm_track"),
"lead_detected_at": payload.get("lead_detected_at"),
"lead_reason": payload.get("lead_reason"),
"funnel_stage": stage,
"last_event_at": last_event,
"created_at": row["created_at"],
"assigned_to": row["assigned_to"] if "assigned_to" in row.keys() else None,
}
def list_leads(conn: sqlite3.Connection, limit: int = 100) -> list[dict[str, Any]]:
rows = conn.execute(
"""
SELECT id, status, session_id, subject, payload, created_at, assigned_to
FROM tickets
ORDER BY id DESC
LIMIT 500
"""
).fetchall()
leads = []
for row in rows:
payload = _parse_payload(row["payload"])
if payload.get("crm_track") != "lead":
continue
leads.append(lead_from_ticket(row, conn))
if len(leads) >= limit:
break
leads.sort(key=lambda x: x.get("lead_detected_at") or "", reverse=True)
return leads
def count_leads(conn: sqlite3.Connection) -> int:
rows = conn.execute("SELECT payload FROM tickets").fetchall()
return sum(1 for row in rows if _parse_payload(row["payload"]).get("crm_track") == "lead")

44
api/app/crm_routes.py Normal file
View file

@ -0,0 +1,44 @@
"""CRM / Leads API — Spec 012."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from app import auth, crm_leads
from app.permissions import can_read_crm_leads
router = APIRouter(prefix="/api/v1/crm", tags=["crm"])
def _db():
from app.main import db
return db()
@router.get("/leads")
def list_crm_leads(user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_read_crm_leads(user.role):
raise HTTPException(403, "insufficient permissions")
with _db() as conn:
leads = crm_leads.list_leads(conn)
stale_hours = crm_leads.ONBOARD_STALE_HOURS
return {
"leads": leads,
"total": len(leads),
"stale_hours": stale_hours,
}
@router.post("/leads/sync")
def sync_stale_leads(
stale_hours: int | None = Query(default=None, ge=0, le=720),
user: auth.DeskUser = Depends(auth.require_internal_or_user),
):
if user.username != "worker" and not can_read_crm_leads(user.role):
raise HTTPException(403, "insufficient permissions")
with _db() as conn:
result = crm_leads.promote_stale_leads(conn, stale_hours=stale_hours)
total = crm_leads.count_leads(conn)
result["leads_total"] = total
return result

Some files were not shown because too many files have changed in this diff Show more