Compare commits

...
3 Commits
Author SHA1 Message Date
james.maesandClaude Opus 5.5 afc9d4d29d docs(spec): gateway LoadBalancer IP 10.100.22.65 (owner, 2026-10-13)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:39:38 -05:00
james.maesandClaude Opus 5.5 0d2943a764 docs(spec): file gateway design (SFTP-only v1, no disk, consumer API)
The approved spec for qbit-file-gateway: a virtual SFTP tree generated
from metadata, uploads into saved bulk-load profiles, downloads of saved
views and reports, SSH-key accounts, audit tables, the consumer and
outbox API agreed with qbit-edi, the core UiApiInvoker additions, the
k8s example and the acceptance tests.

Owner decisions (2026-10-12): all recommendations accepted.
Owner decisions (2026-10-13): separate deployment, /qqq/v1 only,
SSH keys, SFTP only (FTPS future), no files or filesystem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:37:59 -05:00
james.maesandClaude Opus 5.5 a26c518fb2 chore: repo-standard scaffold for qbit-file-gateway
Replaces the application template example with the qbit-file-gateway
skeleton per QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md: pom on
qbit-build-parent with checkstyle and coverage gates, CircleCI on
qqq-orb 0.6.8 with the GA-only release tag filter, RC automation,
dependabot, governance files, Maven wrapper and a minimal producer
and config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:30:36 -05:00
39 changed files with 2298 additions and 1074 deletions

No files matched your search

+58 -14
View File
@@ -1,21 +1,65 @@
version: 2.1
orbs:
qqq-orb: kingsrook/qqq-orb@0.5.0
qqq-orb: kingsrook/qqq-orb@0.6.8
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
workflows:
build:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [ qqq-maven-registry-credentials ]
filters:
branches:
ignore: /(develop|release/.*|integration.*)/
tags:
only: []
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [ qqq-maven-registry-credentials ]
branch_type: snapshot
filters:
branches:
only:
- develop
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [ qqq-maven-registry-credentials ]
branch_type: release_candidate
filters:
branches:
only:
- /release\/.*/
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [ qqq-maven-registry-credentials ]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [ /^v\d+\.\d+\.\d+$/ ]
+20
View File
@@ -0,0 +1,20 @@
# QRun-IO qbit .editorconfig: the Kingsrook layout (3-space indentation, never tabs).
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 3
tab_width = 3
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[*.{cmd,bat}]
end_of_line = crlf
+3
View File
@@ -0,0 +1,3 @@
* text=auto eol=lf
*.cmd text eol=crlf
mvnw text eol=lf
+25
View File
@@ -0,0 +1,25 @@
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
# major updates stay separate so each gets its own review. At most three version-update PRs per
# ecosystem are open at once. Security updates are enabled in the repository settings and are
# not limited by this schedule.
version: 2
updates:
- package-ecosystem: maven
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
maven-minor-and-patch:
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
github-actions:
update-types: [minor, patch]
+31
View File
@@ -0,0 +1,31 @@
# GitHub Release Configuration
# Auto-generates release notes from PR labels
changelog:
exclude:
labels:
- ignore-for-release
- duplicate
- invalid
authors:
- dependabot
- dependabot[bot]
categories:
- title: Breaking Changes
labels:
- breaking-change
- breaking
- title: New Features
labels:
- enhancement
- feature
- title: Bug Fixes
labels:
- bug
- fix
- title: Dependencies
labels:
- dependencies
- title: Other Changes
labels:
- "*"
+25
View File
@@ -0,0 +1,25 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
type: boolean
default: false
pull_request:
types: [closed]
branches: [develop]
permissions:
contents: write
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
+15 -25
View File
@@ -1,31 +1,21 @@
# Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
# IDE
.idea/
.env
.idea
*.iml
.project
.classpath
.settings/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Build
*.class
*.log
*.jar
*.war
*.nar
*.ear
# Logs
*.log
logs/
*.zip
*.tar.gz
*.rar
hs_err_pid*
.DS_Store
*.sw*
.flattened-pom.xml
dependency-reduced-pom.xml
/.env.local
/.cache/
.claude/settings.local.json
+3
View File
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
+51
View File
@@ -0,0 +1,51 @@
# AGENTS.md
Guidance for coding agents working in this repository.
## Overview
QBit File Gateway runs an SFTP server (Apache MINA SSHD) that exposes a QQQ application as a
folder tree: apps, tables, `upload/<bulk-load-profile>/`, `views/`, `reports/`, `results/` and
`archive/`. Users and service accounts sign in with SSH public keys. All data access goes through
the `/qqq/v1` UI API with `UiApiInvoker` on the client channel `sftp`, as the signed-in user; never
through core actions directly. It runs as its own Kubernetes Deployment from the same application
code and QInstance. Design: `docs/DESIGN.md`.
## Build commands
```bash
./mvnw compile
./mvnw test
./mvnw verify # Checkstyle; JaCoCo: 70% instructions, 90% classes
```
Use the Maven 3.9.11 wrapper (Java 21); Maven 3.10 cannot read the parent POM. On Windows use `./mvnw.cmd`.
## Architecture
Package: `com.kingsrook.qbits.filegateway`. Entry point: `FileGatewayQBitProducer implements
QBitMetaDataProducer<FileGatewayQBitConfig>`.
## Invariants and naming
- Data access only through `/qqq/v1` (`UiApiInvoker`, channel `sftp`) as the signed-in user. The
gateway writes only its own `file_gateway_*` tables directly.
- SFTP only, SSH-key auth only. No password login of any kind; plain FTP is never allowed.
- Tables use lowercase snake_case with the `file_gateway_` prefix; backend names equal table
names. Permissions use the full `file_gateway` prefix. Processes are table or record actions,
never navigation entries. One app home.
- No secrets in code or config: host keys come from env-var names.
## Code style
3-space indent, next-line braces, `return (x);`, flower-box Javadoc on classes and methods, fluent `withX()` setters. Follow `checkstyle/` and `CODE_STYLE.md`.
## Dependencies
Parent: `com.kingsrook:qbit-build-parent:2.0.0`. QQQ BOM: `4.1.0-SNAPSHOT` until 4.1.0-RC.6.
Only allow-listed qbit API artifacts (`qbit-object-storage` `api` package) may be depended on.
Notifications go through core `QAlerts.notify`, never qbit-alerts classes.
## Release flow
CircleCI uses `kingsrook/qqq-orb@0.6.8`: develop publishes SNAPSHOTs, release/* publishes RCs, and only `vX.Y.Z` GA tags publish releases. RC automation uses workflow_dispatch or a merged develop PR labelled `release:rc`. Feature branches start from develop and return by PR. Never push `hotfix/*` or `release/*` branches by hand; never publish manually.
+9
View File
@@ -0,0 +1,9 @@
# Changelog
All notable changes are documented here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Initial scaffold
+43 -17
View File
@@ -1,25 +1,51 @@
# qbit-template-application
# CLAUDE.md
GitHub template repository for scaffolding Application QBits (complete mini-applications
with tables, processes, widgets, and UI navigation) on the QQQ platform.
Guidance for coding agents working in this repository.
## QQQ 4.0 baseline — 2026-09-24
## Overview
The historical review below predates the 4.0 migration. This template targets Java 21 and the public QQQ 4.0.0 release from Maven Central. The corrected APIs, license references and actual template commands are in README.md and docs/00-getting-started.md. Preserve the documented multi-instance and scaffold limitations.
QBit File Gateway runs an SFTP server (Apache MINA SSHD) that exposes a QQQ application as a
folder tree: apps, tables, `upload/<bulk-load-profile>/`, `views/`, `reports/`, `results/` and
`archive/`. Users and service accounts sign in with SSH public keys. All data access goes through
the `/qqq/v1` UI API with `UiApiInvoker` on the client channel `sftp`, as the signed-in user; never
through core actions directly. It runs as its own Kubernetes Deployment from the same application
code and QInstance. Design: `docs/DESIGN.md`.
## Knowledge base
## Build commands
Reviewed dossier and platform knowledge live in the second-brain vault:
```bash
./mvnw compile
./mvnw test
./mvnw verify # Checkstyle; JaCoCo: 70% instructions, 90% classes
```
- Hub: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/qqq-hub.md`
- This repo's dossier: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/repos/qbit-template-application.md`
- QBit mechanics refresher: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/architecture/metadata-model.md`
Use the Maven 3.9.11 wrapper (Java 21); Maven 3.10 cannot read the parent POM. On Windows use `./mvnw.cmd`.
Reviewed at commit `fb9395ac0d07` (branch `main`, 2026-07-04).
## Architecture
Key caveat from that review: the scaffold in `src/main` references six qqq APIs that do not
exist in any qqq version (wrong packages for `MetaDataProducerInterface` /
`AbstractWidgetRenderer` / `RenderWidgetInput`, plus nonexistent `QInstance.addAppSection`,
`QProcessMetaData.withInputFields`, `QAppSection.withSourceQBitName`) — it has never compiled,
and CI on `main` is red. Verify against the qqq checkout (`R:/Git.Local/QRunIO/qqq`) before
trusting any API usage shown here.
Package: `com.kingsrook.qbits.filegateway`. Entry point: `FileGatewayQBitProducer implements
QBitMetaDataProducer<FileGatewayQBitConfig>`.
## Invariants and naming
- Data access only through `/qqq/v1` (`UiApiInvoker`, channel `sftp`) as the signed-in user. The
gateway writes only its own `file_gateway_*` tables directly.
- SFTP only, SSH-key auth only. No password login of any kind; plain FTP is never allowed.
- Tables use lowercase snake_case with the `file_gateway_` prefix; backend names equal table
names. Permissions use the full `file_gateway` prefix. Processes are table or record actions,
never navigation entries. One app home.
- No secrets in code or config: host keys come from env-var names.
## Code style
3-space indent, next-line braces, `return (x);`, flower-box Javadoc on classes and methods, fluent `withX()` setters. Follow `checkstyle/` and `CODE_STYLE.md`.
## Dependencies
Parent: `com.kingsrook:qbit-build-parent:2.0.0`. QQQ BOM: `4.1.0-SNAPSHOT` until 4.1.0-RC.6.
Only allow-listed qbit API artifacts (`qbit-object-storage` `api` package) may be depended on.
Notifications go through core `QAlerts.notify`, never qbit-alerts classes.
## Release flow
CircleCI uses `kingsrook/qqq-orb@0.6.8`: develop publishes SNAPSHOTs, release/* publishes RCs, and only `vX.Y.Z` GA tags publish releases. RC automation uses workflow_dispatch or a merged develop PR labelled `release:rc`. Feature branches start from develop and return by PR. Never push `hotfix/*` or `release/*` branches by hand; never publish manually.
+3
View File
@@ -0,0 +1,3 @@
# Code of Conduct
See Project level [Code of Conduct](https://github.com/QRun-IO/qqq/blob/develop/CODE_OF_CONDUCT.md)
+3
View File
@@ -0,0 +1,3 @@
# Code Style Guide
* See Project [Code Style Guide](https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md)
+6
View File
@@ -0,0 +1,6 @@
# Contributing to QBit File Gateway
* See Project [Contributing](https://github.com/QRun-IO/qqq/blob/develop/CONTRIBUTING.md)
* Branches follow GitFlow: `feature/*` from `develop`, pull requests to `develop`, `release/*` for release candidates, `vX.Y.Z` tags for releases.
* Commits are signed and follow Conventional Commits.
* Build and test locally with `./mvnw verify` (Java 21).
+1 -1
View File
@@ -176,7 +176,7 @@
END OF TERMS AND CONDITIONS
Copyright 2022-2025 QRunIO, LLC
Copyright 2024 Kingsrook, LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+3 -3
View File
@@ -1,6 +1,6 @@
qBit Template - Application
Copyright 2022-2025 QRunIO, LLC
QBit File Gateway
Copyright 2026 Kingsrook, LLC
This product includes software developed at QRunIO, LLC (https://qrun.io/).
This product includes software developed at Kingsrook, LLC (https://kingsrook.com/).
Licensed under the Apache License, Version 2.0.
+43 -42
View File
@@ -1,55 +1,56 @@
# QBit Template - Application
# QBit File Gateway
Template repository for creating **Application QBits** - complete, self-contained applications or major functional modules for the QQQ framework.
An SFTP server for QQQ applications. Users and service accounts sign in with their SSH keys and see
the application as a folder tree of apps and tables. Uploading a file into a table's
`upload/<profile>/` folder runs a saved bulk-load profile as that user; getting a file from
`views/` or `reports/` exports a saved view or runs a saved report. The gateway reaches data only
through the `/qqq/v1` UI API with the signed-in user's own session (client channel `sftp`), so
permissions, masking and audits apply exactly as in the UI. Design: [docs/DESIGN.md](docs/DESIGN.md).
## When to Use This Template
This repository currently holds the scaffold; the gateway is built in phases described in the design.
Use this template when building:
- Complete mini-applications (WMS, OMS, CRM)
- Major functional modules with multiple tables
- QBits that require UI navigation (QAppSection)
- Self-contained features with business logic
## Requirements
## Quick Start
Java 21 and QQQ 4.1.0-SNAPSHOT (UI API invoker and client channels, until 4.1.0-RC.6). The Maven
wrapper pins Maven 3.9.11.
Requires **Java 21**, **Maven 3.8+**, and QQQ **4.0.0**. Create a repository with **Use this template**, customize its Maven coordinates and Java package/classes, then run `mvn clean verify`. Follow the [Getting Started guide](docs/00-getting-started.md) for the full sequence.
## Install
## Structure
```
src/main/java/com/kingsrook/qbits/example/
├── ExampleAppQBitConfig.java
├── ExampleAppQBitProducer.java
├── model/
│ ├── ExampleEntity.java
│ └── ExampleChildEntity.java
├── processes/
│ ├── ExampleProcessStep.java
│ └── ExampleProcessMetaDataProducer.java
└── widgets/
├── ExampleDashboardWidgetRenderer.java
└── ExampleDashboardWidgetMetaDataProducer.java
```xml
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-file-gateway</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>
```
## Key Characteristics
## Tables and permissions
| Feature | Description |
|---------|-------------|
| QAppSection | Required - provides UI navigation |
| Tables | Multiple interconnected business tables |
| Processes | Full CRUD with business logic |
| Widgets | Dashboard components |
| Security | Permission key support |
| Integration | Hooks for other QBits |
Planned tables (none are produced yet), all prefixed `file_gateway_`: accounts, SSH keys, aliases,
upload jobs, the transfer log and the connection log. Permissions use the full `file_gateway`
prefix.
## Documentation
## Configuration
- [Getting Started](docs/00-getting-started.md)
- [Tables and Relationships](docs/01-tables-and-relationships.md)
- [Processes](docs/02-processes.md)
- [Widgets and UI](docs/03-widgets-and-ui.md)
`FileGatewayQBitConfig` holds the optional `tableMetaDataCustomizer`, `sftpEnabled` (only the
gateway deployment runs the server) and `sftpPort` (default 2222).
## See Also
## Adopting in another app
- [qbit-template-extension](https://github.com/QRun-IO/qbit-template-extension) - For infrastructure extensions
- [qbit-template-data](https://github.com/QRun-IO/qbit-template-data) - For reference data providers
```java
new FileGatewayQBitProducer()
.withQBitConfig(new FileGatewayQBitConfig())
.produce(qInstance);
```
## Build
```bash
./mvnw verify # Checkstyle; JaCoCo 70% instructions, 90% classes
```
On Windows, use `./mvnw.cmd`.
## License
[Apache License 2.0](LICENSE). Copyright Kingsrook, LLC.
+4
View File
@@ -0,0 +1,4 @@
# Security Policy
* See Project [Security Policy](https://github.com/QRun-IO/qqq/blob/develop/SECURITY.md)
* Report suspected vulnerabilities privately through GitHub private vulnerability reporting on this repository, never in a public issue.
+246
View File
@@ -0,0 +1,246 @@
<?xml version="1.0"?>
<!--
~ QQQ - Low-code Application Framework for Engineers.
~ Copyright (C) 2021-2022. Kingsrook, LLC
~ 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
~ contact@kingsrook.com
~ https://github.com/Kingsrook/
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<!DOCTYPE module PUBLIC
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
"http://checkstyle.sourceforge.net/dtds/configuration_1_3.dtd">
<!--
See reference at https://checkstyle.sourceforge.io/checks.html
-->
<module name = "Checker">
<property name="charset" value="UTF-8"/>
<property name="severity" value="warning"/>
<property name="fileExtensions" value="java, properties, xml"/>
<!-- Checks for whitespace -->
<!-- See http://checkstyle.sf.net/config_whitespace.html -->
<module name="FileTabCharacter">
<property name="eachLine" value="true"/>
</module>
<!--
<module name="LineLength">
<property name="max" value="100"/>
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
</module>
-->
<module name="TreeWalker">
<module name="SuppressWarningsHolder"/>
<module name="OuterTypeFilename"/>
<module name="IllegalTokenText">
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
<property name="format" value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
<property name="message" value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
</module>
<module name="AvoidEscapedUnicodeCharacters">
<property name="allowEscapesForControlCharacters" value="true"/>
<property name="allowByTailComment" value="true"/>
<property name="allowNonPrintableEscapes" value="true"/>
</module>
<module name="AvoidStarImport"/>
<module name="OneTopLevelClass"/>
<module name="NoLineWrap"/>
<module name="EmptyBlock">
<property name="option" value="TEXT"/>
<property name="tokens" value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
</module>
<module name="NeedBraces"/>
<module name="LeftCurly">
<property name="option" value="nl"/>
</module>
<module name="RightCurly">
<property name="option" value="alone"/>
</module>
<!--
<module name="WhitespaceAround">
<property name="allowEmptyConstructors" value="true"/>
<property name="allowEmptyMethods" value="true"/>
<property name="allowEmptyTypes" value="true"/>
<property name="allowEmptyLoops" value="true"/>
<message key="ws.notFollowed"
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
<message key="ws.notPreceded"
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
</module>
-->
<module name="OneStatementPerLine"/>
<module name="MultipleVariableDeclarations"/>
<module name="ArrayTypeStyle"/>
<module name="MissingSwitchDefault"/>
<module name="FallThrough"/>
<module name="UpperEll"/>
<module name="ModifierOrder"/>
<module name="EmptyLineSeparator">
<property name="allowNoEmptyLineBetweenFields" value="true"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapDot"/>
<property name="tokens" value="DOT"/>
<property name="option" value="nl"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapComma"/>
<property name="tokens" value="COMMA"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/258 -->
<property name="id" value="SeparatorWrapEllipsis"/>
<property name="tokens" value="ELLIPSIS"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/259 -->
<property name="id" value="SeparatorWrapArrayDeclarator"/>
<property name="tokens" value="ARRAY_DECLARATOR"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapMethodRef"/>
<property name="tokens" value="METHOD_REF"/>
<property name="option" value="nl"/>
</module>
<module name="PackageName">
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
<message key="name.invalidPattern"
value="Package name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="TypeName">
<message key="name.invalidPattern"
value="Type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MemberName">
<property name="format" value="^[_a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Member name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="CatchParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="LocalVariableName">
<property name="tokens" value="VARIABLE_DEF"/>
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Local variable name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ClassTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Class type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MethodTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Method type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="InterfaceTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Interface type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="NoFinalizer"/>
<module name="GenericWhitespace">
<message key="ws.followed" value="GenericWhitespace ''{0}'' is followed by whitespace."/>
<message key="ws.preceded" value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
<message key="ws.illegalFollow" value="GenericWhitespace ''{0}'' should followed by whitespace."/>
<message key="ws.notPreceded" value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
</module>
<module name="Indentation">
<property name="basicOffset" value="3"/>
<property name="braceAdjustment" value="0"/>
<property name="caseIndent" value="3"/>
<property name="throwsIndent" value="6"/>
<property name="lineWrappingIndentation" value="3"/>
<property name="arrayInitIndent" value="6"/>
</module>
<!--
<module name="AbbreviationAsWordInName">
<property name="ignoreFinal" value="false"/>
<property name="allowedAbbreviationLength" value="1"/>
</module>
<module name="OverloadMethodsDeclarationOrder"/>
<module name="VariableDeclarationUsageDistance"/>
<module name="CustomImportOrder">
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="true"/>
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
</module>
-->
<module name="CustomImportOrder">
<property name="customImportOrderRules" value="SPECIAL_IMPORTS###STANDARD_JAVA_PACKAGE###THIRD_PARTY_PACKAGE###STATIC"/>
<property name="specialImportsRegExp" value="^javax\."/>
<property name="standardPackageRegExp" value="^java\."/>
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="false"/>
</module>
<module name="MethodParamPad"/>
<module name="NoWhitespaceBefore">
<property name="tokens" value="COMMA, SEMI, POST_INC, POST_DEC, DOT, ELLIPSIS, METHOD_REF"/>
<property name="allowLineBreaks" value="true"/>
</module>
<module name="ParenPad"/>
<module name="OperatorWrap">
<property name="option" value="NL"/>
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF "/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationMostCases"/>
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF"/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationVariables"/>
<property name="tokens" value="VARIABLE_DEF"/>
<property name="allowSamelineMultipleAnnotations" value="true"/>
</module>
<module name="JavadocMethod">
<property name="allowMissingParamTags" value="true"/>
<property name="allowMissingReturnTag" value="true"/>
<property name="allowedAnnotations" value="Override, Test"/>
</module>
<module name="MissingJavadocMethod">
<property name="scope" value="private"/>
</module>
<module name="MissingJavadocType">
<property name="scope" value="private"/>
</module>
<module name="MethodName">
<property name="format" value="^[a-z][a-zA-Z0-9_]*$"/>
<message key="name.invalidPattern"
value="Method name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="EmptyCatchBlock">
<property name="exceptionVariableName" value="expected"/>
</module>
<module name="CommentsIndentation"/>
<!-- this would be nice, but requires the the @inheritDoc javadoc tag... -->
<module name="MissingOverride"/>
</module>
<module name="Header">
<property name="headerFile" value="checkstyle/license.txt"/>
<property name="fileExtensions" value="java"/>
<property name="ignoreLines" value="3"/>
</module>
<module name="SuppressWarningsFilter"/>
</module>
+19
View File
@@ -0,0 +1,19 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-30
View File
@@ -1,30 +0,0 @@
# Getting Started
Targets Java 21, Maven 3.8 or later, and QQQ 4.0.0. Maven resolves the framework dependencies from Maven Central.
1. Choose **Use this template** on GitHub, then clone the repository you created.
2. In `pom.xml`, set your own `groupId`, `artifactId`, name, description, and version.
3. Use your IDE's package refactoring to rename `com.kingsrook.qbits.example` throughout `src/`. Rename the `Example*` classes and update their imports and references with the IDE's rename refactoring.
4. Update the QBit producer's `GROUP_ID`, `ARTIFACT_ID`, and `VERSION` constants to match your project. Give the metadata names their own stable names before combining the QBit with other examples.
5. Build the generated project:
```bash
mvn clean verify
```
The repository provides example Java sources; customization uses normal package/class refactoring. Keep example coordinates unpublished. After customizing, add tests for your QBit's behavior before setting up its publishing workflow.
## Register with a host application
The following uses the original example names; substitute the names chosen above:
```java
new ExampleAppQBitProducer()
.withConfig(new ExampleAppQBitConfig()
.withBackendName("rdbms"))
.produce(qInstance, "my-application");
```
Register the configured backend in the host first. If you add table prefixes or multiple QBit instances, update related table references, possible-value-source names, and process/widget names consistently. A table prefix alone does not namespace all metadata.
[Tables and Relationships](01-tables-and-relationships.md) describes the model; the `processes/` and `widgets/` directories contain the remaining examples.
-50
View File
@@ -1,50 +0,0 @@
# Tables and Relationships
Application QBits typically have multiple interconnected tables representing a business domain.
## Entity Design
Use `@QMetaDataProducingEntity` for automatic metadata generation:
```java
@QMetaDataProducingEntity(producePossibleValueSource = true)
public class Order extends QRecordEntity
{
public static final String TABLE_NAME = "order";
@QField(isPrimaryKey = true)
private Integer id;
@QField(isRequired = true, possibleValueSourceName = "customer")
private Integer customerId;
@QField(isRequired = true)
private String status;
}
```
## Relationships
Define foreign keys using `possibleValueSourceName`:
```java
@QField(isRequired = true, possibleValueSourceName = "exampleEntity")
private Integer exampleEntityId;
```
## Table Prefixing
Application QBits support optional table prefixing:
```java
// Without prefix: order, orderLine
// With prefix "sales": sales_order, sales_orderLine
new MyAppQBitProducer()
.withConfig(new MyAppQBitConfig()
.withBackendName("rdbms")
.withTableNamePrefix("sales"))
.produce(qInstance, "sales-app");
```
The producer applies the prefix to table names. When extending this pattern, also update possible-value-source names, relationship targets, process tables, and navigation references; table renaming alone does not provide complete multi-instance support.
-7
View File
@@ -1,7 +0,0 @@
# Processes
Start with [ExampleProcessMetaDataProducer](../src/main/java/com/kingsrook/qbits/example/processes/ExampleProcessMetaDataProducer.java) and [ExampleProcessStep](../src/main/java/com/kingsrook/qbits/example/processes/ExampleProcessStep.java). The producer defines the process, its table, inputs, and backend step; the step transforms `PENDING` records to `PROCESSED` and returns them in the output.
In QQQ 4.0, import `MetaDataProducerInterface` from `com.kingsrook.qqq.backend.core.model.metadata`. Define process inputs through `withInputMetaData(new QFunctionInputMetaData().withField(...))`. An `AbstractTransformStep` must implement both `runOnePage(RunBackendStepInput, RunBackendStepOutput)` and `getProcessSummary(RunBackendStepOutput, boolean)`; keep the example's implementations when renaming it.
To make the process discoverable in navigation, add its registered name to a `QAppSection` through `withProcesses(List.of(ExampleProcessMetaDataProducer.NAME))`, then include the section in a `QAppMetaData` registered with `qInstance.addApp(...)`. Use the actual registered name if you introduce a prefix. Add tests for record changes and failure cases when replacing the example business logic.
-23
View File
@@ -1,23 +0,0 @@
# Widgets and UI
The application producer registers a `QAppMetaData` containing a `QAppSection`. The section references metadata names already registered in the instance; it is not registered directly on `QInstance`.
```java
QAppSection section = new QAppSection()
.withName("exampleSection")
.withLabel("Example Application")
.withTables(List.of(ExampleEntity.TABLE_NAME, ExampleChildEntity.TABLE_NAME))
.withProcesses(List.of(ExampleProcessMetaDataProducer.NAME))
.withWidgets(List.of(ExampleDashboardWidgetMetaDataProducer.NAME));
qInstance.addApp(new QAppMetaData()
.withName("exampleApp")
.withLabel("Example Application")
.withSection(section));
```
Use [ExampleAppQBitProducer](../src/main/java/com/kingsrook/qbits/example/ExampleAppQBitProducer.java) as the starting point. Keep these references consistent when enabling/disabling tables or introducing prefixes.
The widget has two parts: [its metadata producer](../src/main/java/com/kingsrook/qbits/example/widgets/ExampleDashboardWidgetMetaDataProducer.java) declares a statistics widget and its renderer, and [its renderer](../src/main/java/com/kingsrook/qbits/example/widgets/ExampleDashboardWidgetRenderer.java) returns a `RenderWidgetOutput` containing `StatisticsData`. Replace the example count with a query or calculation. Choose a widget type that matches the returned data model.
For QQQ 4.0, `AbstractWidgetRenderer` is in `com.kingsrook.qqq.backend.core.actions.dashboard.widgets`; `RenderWidgetInput` and `RenderWidgetOutput` are in `com.kingsrook.qqq.backend.core.model.actions.widgets`.
+721
View File
@@ -0,0 +1,721 @@
# QBit File Gateway: design
Status: **Approved** (owner, 2026-10-13) · drafted 2026-10-13 · Author: James Maes (drafted with Claude)
Targets:
- **`QRun-IO/qbit-file-gateway`** (Apache-2.0): the SFTP server, the virtual filesystem, accounts
and keys, uploads, downloads, endpoints (aliases), the consumer and outbox API, audit tables and
the app home.
- **QQQ core 4.1.x** (`qqq-middleware-javalin`): three small additions to `UiApiInvoker` (§3). The
gateway needs them because it acts as a user who signed in with an SSH key, not through an HTTP
request, and because it moves files rather than JSON.
- **`QRun-IO/qbit-authentik`**: permissions for automated sessions (§3.4).
- **kof22** (`KofTwentyTwo/Website-Backend`, `KofTwentyTwo/cluster-gitops`): adoption, the gateway
Deployment and its LoadBalancer IP (§11).
> **Owner decisions (2026-10-12): all recommendations accepted.** Every open question in this spec
> is decided as recommended ("they are approved. we will adjust as needed once we can view them
> working").
>
> **Owner decisions (2026-10-13):**
> - **A separate gateway deployment.** It runs the same application code and QInstance as its own
> Kubernetes Deployment, with its own LoadBalancer IP and port, and scales independently.
> - **Data only through `/qqq/v1`.**
> - The gateway reaches application data ONLY through the UI API, as the signed-in user, via
> `UiApiInvoker` on the client channel `sftp`.
> - It never calls core actions on application data.
> - It writes its own `file_gateway_*` tables directly.
> - **Auth is SSH public keys per user.**
> - Users add keys on the core Preferences page, in a "File transfer" section.
> - Service accounts (Authentik service users) also get keys, plus IP allowlists, quotas and
> expiry.
> - **Scope change: v1 is SFTP ONLY, with SSH-key auth.**
> - FTPS is dropped from v1, and there is no password login of any kind (no password and no
> keyboard-interactive). Plain FTP is never allowed.
> - The auth and protocol layers stay pluggable (§6.5).
> - "FTPS (future)" is in §15. It would use revocable app passwords and/or client certificates
> plus a passive port range, and is added only if a partner needs it.
> - The former phase (d), FTPS, is removed. The k8s example has one SFTP port and no passive
> range, and the tests use only the MINA SFTP client.
> - **No files or filesystem: just the database and S3.** The owner's words: "there are no files
> or filesystem - just the database and s3".
> - The SFTP tree is purely virtual, built from metadata and data rows.
> - Uploads stream directly to object storage (qbit-object-storage), with database rows.
> - Downloads are generated or streamed from export, report or object storage.
> - There is NO local disk persistence of any kind: no spool directories and no home
> directories.
> - Temporary buffering is in memory (bounded). Where a library insists on a file, the gateway
> uses a bounded area under the JVM temp directory and deletes it in a `finally` block.
> Streaming is preferred everywhere.
> - The pod is stateless and replaceable at any time. The host key and configuration come from
> secrets and database rows.
> - A test asserts that nothing is written outside the JVM temp area, and that the temp area is
> empty after each transfer (§12).
> - **The consumer and outbox API (coordinator, with qbit-edi).**
> - A small allow-listed API package, `com.kingsrook.qbits.filegateway.api` (guardrail G1).
> - It defines a consumer SPI for inbound files and an outbox for partners that pull (§9).
> - Bulk load is the default consumer.
> - Consumers are bound to endpoint folders by data (`consumer_name` on the endpoint row).
> - The shape was agreed with the qbit-edi agent on 2026-10-13.
## Owner request (2026-10-13, verbatim)
> the sftp/ftps idea is that the server would run a sftp/ftps server itself, and you would log in
> via your account or a service account etc. and you could cd into apps, tables, etc. and from
> there you can upload files, that would go into a bulk upload profile into that table, and you can
> download that table via getting a saved view etc. same with the ability to 'get' a saved report
> etc. allowing for complex data flows without any coding, just setting up bulk upload profiles and
> views and reports etc.
## Mandatory standards that shape this design
- **Data.** It lives in standard, visible QQQ tables (`file_gateway_*`) with full record screens,
secured by permissions. Permissions use the full name (`file_gateway.*`).
- **Processes.**
- Processes are table or record actions with plain labels, never in the nav.
- Record actions use the standard streamed-ETL Review/Result summary.
- **Navigation.** One app home owns the data. The nav lists only the main tables; SSH keys and
outbox files are child lists.
- **API and search.** The API matches the admin. The main tables publish ESB table events and are
in Quick Search.
- **Notifications and alerts.** Notifications go through core `QAlerts.notify` (the bell) and
alerts through `QAlerts.raise`. There is never a dependency on qbit-alerts classes.
- **Storage** goes through qbit-object-storage (`com.kingsrook.qbits.objectstorage.api`).
- **Display.** Pages are readable at all widths, with no double scrollbars, in light and dark
mode. Typed widgets only, never HTML widgets (G5).
## 1. Architecture
```
partner / user SFTP client
│ ssh (publickey only), port 22 → 2222
▼
┌─────────── gateway Deployment (same image, FILE_GATEWAY_SFTP_ENABLED=true, stateless) ───────┐
│ Apache MINA SSHD ──► GatewayPublickeyAuthenticator ──► file_gateway_account / _ssh_key │
│ │ (lockout, CIDR allowlist, expiry, limits) │
│ ▼ │
│ SFTP subsystem ──► GatewayFileSystem (NIO provider over the VirtualTree; no disk) │
│ │ │ list / stat / open / write / rename / remove │
│ │ ▼ │
│ │ GatewayUiApiClient ── UiApiInvoker(channel "sftp", invokedBy "file-gateway")│
│ │ │ credentials = in-process session minted for the account's user │
│ │ ▼ (loopback HTTP to this pod's own QApplicationJavalinServer) │
│ │ /qqq/v1: metaData, table query, export, process init/step, download │
│ ▼ │
│ uploads ──stream──► object storage (sha256, dedupe) ──► UploadJobWorker ──► consumer │
│ (bulk-load | edi | …) │
│ Audit writer ──► file_gateway_connection_log / _transfer_log (system session, own tables) │
└───────────────────────────────────────────────────────────────────────────────────────────────┘
main web Deployment: same code, FILE_GATEWAY_SFTP_ENABLED=false (admin tables, app home, prefs)
```
- **The gateway pod runs the normal application,** including its `QApplicationJavalinServer`. The
invoker calls the pod's own `/qqq/v1` on `127.0.0.1`. The Service exposes only the SFTP port;
HTTP serves only loopback calls and kubelet probes.
- **Everything the user sees or does is a `/qqq/v1` call as that user.** So the following all
apply exactly as in the UI:
- permissions;
- secured-data masking (enforced in core under the UI API);
- audits (`audit.clientChannel = sftp`);
- record locks;
- table automations.
- **The gateway's own bookkeeping is written server-side** under a system session, to its own
`file_gateway_*` tables. That covers connection and transfer logs, upload jobs, lockout counters
and outbox rows. These tables are not application data, and the SFTP user has no permission on
them.
- **State lives only in the database and object storage.**
- A pod can be killed at any moment.
- Open sessions drop; the client reconnects to another pod.
- An upload whose stream did not complete stays `RECEIVING`, and the retention job aborts it
after 1 hour, deleting its partial object.
- Claimed jobs are requeued (§4).
## 2. The virtual filesystem
The tree is generated from metadata and data, and filtered by the user's permissions. Nothing
exists on any disk.
```
/ apps the user can see (GET /metaData), as folders
/<app>/ child apps and the app's tables (by table name)
/<app>/<table>/
upload/ one folder per saved bulk-load profile the user can see
<profile-slug>/ write a file here to bulk-load it with that profile
<file>.processing sidecar while queued or running (from the job row)
<file>.done | .error sidecar when finished (text summary, from the job row)
views/<view-slug>.<csv|xlsx|json> saved views of this table (generated on read)
reports/<report-slug>.<csv|xlsx|json> saved reports on this table (generated on read)
results/<jobId>-<file>.results.csv per-row results of this account's uploads (object storage)
archive/<yyyy-mm-dd>/<jobId>-<file> the original uploaded files (object storage, retention §8)
/<endpoint path>/ endpoints (aliases) bound to this account, §2.3
```
### 2.1 Names
- **Apps and tables** use their QQQ `name`, which is stable and has no spaces.
- **Saved profiles, views and reports** use a slug of their label: lowercase, `[a-z0-9-]`, at
most 80 characters.
- When labels collide within a folder, both get `-<id>` appended.
- Slugs are computed per listing, so a rename shows on the next listing.
- **Formats** are `csv`, `xlsx` and `json`, the formats of `/table/{t}/export`. Each view and
report is listed once per format enabled for the account (`formats`; default csv and xlsx).
### 2.2 Visibility rules (all from the user's own UI API calls)
| Node | Shown when |
|---|---|
| app | the app is in the user's `GET /metaData` app tree |
| table | the table is in the app and in `metaData.tables` (read permission) |
| `upload/` | the table has the bulk-insert process (`<table>.bulkInsert`) in metaData, and at least one saved bulk-load profile is visible (`POST /table/savedBulkLoadProfile/query` filtered by `tableName`) |
| `views/` | the user can query `savedView` |
| `reports/` | the user can query `savedReport` and has the `renderSavedReport` process |
| `results/`, `archive/` | always (they hold only this account's jobs) |
- **Caching.** Metadata responses are cached in memory per SFTP session for 60 seconds. Saved-object
lists are cached for 30 seconds.
- **Empty folders.** A folder is listed even when empty, once its parent's rule passes, so
partners can see where to put files.
- **Hidden paths.** A path the user cannot see answers "no such file", never "permission denied".
### 2.3 Endpoints (aliases) and chroot
A `file_gateway_alias` row gives a partner-friendly path, such as `/acme/orders-in`, that maps to
one target:
| `kind` | Target | Folder behaviour |
|---|---|---|
| `BULK_LOAD` | table + saved bulk-load profile | an upload folder (§4) |
| `INBOX` | `consumer_name` | an upload folder whose files go to a consumer (§9) |
| `VIEW` | saved view + format + file name | one generated file |
| `REPORT` | saved report + format + file name | one generated file |
| `OUTBOX` | outbox files for this endpoint | files put by `FileGatewayOutbox` (§9); partners download and delete them |
- **Endpoints grant nothing.**
- Every endpoint operation is the same `/qqq/v1` call as the user, through the full path.
- An endpoint whose target the user cannot reach is not listed.
- INBOX and OUTBOX endpoints need the account to be bound to them: either `account_id` matches,
or `account_id` is null (shared).
- **Root mode.** The account's `root_mode` sets what `/` shows:
- `FULL_TREE` (the default for users) shows the app tree plus the endpoints.
- `ENDPOINTS_ONLY` (the default for service accounts) shows only the account's endpoints. This
is the chroot: `/` lists the endpoints' top-level folders.
### 2.4 Operations
| SFTP operation | Allowed on | Behaviour |
|---|---|---|
| `ls`, `stat` | everything visible | Generated files report size 0 and the current time. Object-storage files report their stored size. |
| `get` | views, reports, results, archive, outbox files | Generated or streamed on read (§5). |
| `put` | `upload/<profile>/` and BULK_LOAD/INBOX endpoints | Streams to object storage, then queues a job (§4). |
| `rename` | inside one upload folder | Supports clients that upload `x.filepart` or `x.tmp`, then rename. A job is queued on close only for a non-temporary name, or on the rename to one. |
| `rm` | sidecars, `results/*` and outbox files | On a sidecar or result it acknowledges the job, which hides its sidecar and results. On an outbox file it marks the file removed. |
| `rm` (other paths), `mkdir`, `rmdir`, `setstat`, symlinks | none | Denied. |
| overwrite | none | A second put of the same name is a new job, subject to dedupe. |
Temporary suffixes are `.filepart`, `.tmp`, `.part` and `.partial`, configurable as
`temporaryUploadSuffixes`.
## 3. Core dependencies
### 3.1 In-process sessions for `UiApiInvoker` (qqq-middleware-javalin)
The invoker forwards the caller's HTTP credentials, but an SFTP user has none. The solution is an
in-process session token; there is never a service session.
- **Registering.** `UiApiInProcessSessions.register(QSession session, Duration ttl)` returns a
random 256-bit token, held only in this JVM's memory. `revoke(token)` and expiry remove it.
- **Sending.** `UiApiCredentials.ofInProcessSession(token)` puts the token in the
`X-QQQ-In-Process-Session` header on the loopback request.
- **Accepting.** `ExecutorSessionUtils.setupSession` (and the legacy
`QJavalinImplementation.setupSession`) accept the header only when both are true:
- the request's remote address is a loopback address;
- the token is registered and unexpired in this JVM.
Otherwise the request gets a 401, with no fallback to other credentials.
- **Per-request copy.** The registered session is copied per request. The client channel is still
set from `X-QQQ-Client`, which the invoker fixes (§6.4).
- **Minting.**
- The gateway builds the session with the application's authentication module:
`createAutomatedSessionForUser(qInstance, account.userId)`. Scheduled saved reports already
use this. Under OAuth2 it calls the customizer's `customizeAutomatedSessionForUser`.
- A session is minted at SFTP login and re-minted every 5 minutes, so permission changes and
revocations apply quickly.
- It is revoked at disconnect, with a TTL of 10 minutes.
### 3.2 Binary responses
- `UiApiInvoker.invokeForStream(UiApiRequest)` returns `UiApiStreamResponse`: `AutoCloseable`,
with the status, headers and an `InputStream` (`HttpResponse.BodyHandlers.ofInputStream`).
- It is used for `POST /table/{t}/export`, `GET /download/{file}` and `GET /reports/{name}`.
- Bodies are never buffered whole.
### 3.3 Multipart uploads
- `UiApiRequest.withMultipartFile(fieldName, fileName, contentType, Supplier<InputStream>)`, plus
form fields.
- It is sent as streamed `multipart/form-data`.
- It is used for `POST /processes/{p}/init`, the bulk-load upload step
(`ProcessSpecUtilsV1.addUploadedFiles`).
### Shipping
§3.1–3.3 go in one core PR to `QRun-IO/qqq` `develop`, for 4.1.0-RC.6. Until then the qbit builds
on `4.1.0-SNAPSHOT`, with the pom marked `STAND-IN(removeBy=4.1.0-RC.6)`.
### 3.4 Permissions for automated sessions (qbit-authentik)
`AuthentikGroupPermissionMapper` builds permissions from token claims, and an automated session
has no token.
- **The change.** It implements `customizeAutomatedSessionForUser`:
- it looks the user up by id through the Authentik API backend (the cached group graph);
- it applies the same group, ancestor, fallback and bootstrap rules;
- if Authentik cannot be read, it grants only the static fallbacks.
- **Service users** are Authentik users with `type=service_account`.
- **Who needs it.** It is a qbit-authentik PR. The gateway works without it under MOCK auth and in
tests, but kof22 needs it before going live.
### 3.5 Preferences (QRun-IO/qqq#1162)
The gateway registers a "File transfer" section with the provider `FileTransferPreferencesProvider`.
- **Scalar fields:** the SFTP username (read-only), "Add a public key" (TEXT) and "Key label".
- **The `keys` list:** label, fingerprint (read-only), last used (read-only), expires and revoked.
- **Behaviour.** The first key creates the user's `USER` account. Revoking a key sets `revoked_at`.
- **Until #1162 is in the baseline,** admins add keys on the account record.
## 4. Upload
1. **Receive.** The SFTP write streams straight to object storage:
- The key is `file-gateway/uploads/{accountId}/{yyyy}/{mm}/{dd}/{uuid}/{fileName}`, in the bucket
alias `fileGatewayBucketAlias` (default `file-gateway`).
- Writes go out as multipart-upload parts of 8 MiB, buffered in memory.
- SHA-256 and the size are computed in flight. `max_file_size_mb` and the daily quota are
enforced while writing; an overrun aborts the write with a failure status.
- Clients write SFTP blocks at increasing offsets. Out-of-order blocks are held in a bounded
in-memory window (64 MiB per upload); a gap larger than the window fails the write.
- A `file_gateway_upload_job` row is created in `RECEIVING`.
2. **Close.**
- The multipart upload is completed.
- The job becomes `DUPLICATE` (with `duplicate_of_job_id`) when the account has
`dedupe_uploads` and a job with the same account, target and sha256 succeeded within
`dedupeWindow` (7 days). Nothing runs, and the `.done` sidecar says so.
- Otherwise the job becomes `QUEUED`.
3. **Scan (optional).** `FileGatewayScannerInterface` (a `QCodeReference` in the config) reads the
object as a stream and returns CLEAN, INFECTED or ERROR.
- INFECTED: the job becomes `REJECTED`, and the object is copied to `file-gateway/quarantine/`
and deleted from the upload key.
- ERROR: the scan is retried.
4. **PGP (optional, phase e).** When the account has `pgp_decrypt_uploads` and the name ends in
`.pgp` or `.gpg`:
- the object is decrypted stream to stream into a new object, using the key named by
`pgpPrivateKeyEnvVarName`;
- the plaintext's sha256 is recorded.
5. **Run.** The worker hands the job to the endpoint's consumer (§9). For `upload/<profile>/`
folders that is `bulk-load`, which runs the table's bulk-insert process as the user, all through
`/qqq/v1`:
- `POST /processes/{table}.bulkInsert/init`, multipart, streaming the object (§3.3);
- `POST /processes/{p}/{uuid}/step/receiveFileMapping` with `savedBulkLoadProfileId`, which
applies the saved mapping;
- if value mapping is requested, the job **fails** with "this profile needs value mappings
saved in it"; the gateway never guesses;
- continue through review, execute and result, polling `/status` for async steps;
- read the summary lines and per-row errors from the result.
6. **Finish.**
- The job ends `SUCCEEDED`, `PARTIAL` (some rows failed) or `FAILED`, recording counts, the
summary and `process_uuid`.
- A results CSV (row, field, message, plus the summary lines) is written to object storage,
straight from memory.
- The sidecar becomes `.done` or `.error`.
- The original appears in `archive/`, as the same object.
7. **Notify.** `QAlerts.notify` goes to the account's user, or to `owner_user_id` for service
accounts:
- always on FAILED, PARTIAL and REJECTED;
- on SUCCEEDED only when `notify_on_success` is set.
The notification links to the upload-job record.
8. **Worker.**
- Bounded pool per gateway pod: `uploadWorkerThreads`, default 4.
- A worker claims a job by updating QUEUED to RUNNING, setting `claimed_by` (the pod) and
`claimed_at`.
- A job stuck RUNNING longer than `jobStaleAfter` (30 minutes), for example after a killed pod,
is requeued, up to 3 attempts.
- The record action "Retry upload" requeues a FAILED or REJECTED job from its stored object.
## 5. Download
Everything is streamed. Nothing is written to disk.
- **Saved view** (`views/<slug>.<fmt>`):
- `POST /table/savedView/query` by table name, as the user;
- the filter, visible columns and order are parsed from `viewJson`;
- `POST /table/{table}/export` with `format`, `filter` and `fieldNames`, through
`invokeForStream`.
- **Saved report** (`reports/<slug>.<fmt>`):
- `POST /processes/renderSavedReport/init` with `recordIds=<id>` and `reportFormat`;
- `GET /download/{file}?storageTableName&storageReference` from the process output, streamed.
- **Generating on open.**
- The export starts when the client opens the file, and SFTP reads are served from the stream
in order.
- Clients pipeline reads at increasing offsets. A bounded in-memory read-ahead window (16 MiB per
open file) absorbs reordering.
- A read before the window (a backward seek) re-opens the stream for objects in storage. For
generated files it fails, telling the client to download sequentially.
- Every common client (OpenSSH `sftp`, WinSCP, FileZilla, lftp, MINA) reads sequentially.
- **Limits:** the account's `max_download_mb_per_day` and `maxGeneratedFileMb` (default 1024),
counted as bytes flow.
- **Masking.** Secured-data masking applies because core enforces it under the UI API: export and
report bytes carry exactly what the user may see.
- **Results, archive and outbox** files stream from object storage with ranged reads, so seeks
work.
- **PGP (phase e).** When the account has `pgp_encrypt_downloads`, the stream is encrypted on the
fly to the account's `pgp_public_key`, and `.pgp` is appended to the listed names.
## 6. Protocol and security
### 6.1 SFTP server
- **Server.** Apache MINA SSHD (`sshd-core`, `sshd-sftp`), SFTP subsystem only.
- Shell, exec, port forwarding, agent forwarding and X11 are disabled.
- No `VirtualFileSystemFactory` or native filesystem is ever used. The SFTP subsystem's
filesystem is the gateway's NIO provider over the virtual tree.
- **Auth: public key only.** `setPasswordAuthenticator(null)`; keyboard-interactive, GSSAPI and
host-based auth are disabled.
- **Algorithms offered:**
- KEX: curve25519, ecdh-nistp256/384/521, and DH group16/18 with sha512;
- ciphers: chacha20-poly1305, aes-gcm and aes-ctr;
- MACs: hmac-sha2-256/512 (etm).
- **Never offered:** CBC, SHA-1 MACs or KEX, or `ssh-rsa` (SHA-1) signatures.
- **User keys:** Ed25519, ECDSA, and RSA of at least 3072 bits (rsa-sha2-256/512).
- **Host key.**
- The OpenSSH private key comes from the env var named by `hostKeyEnvVarName` (default
`FILE_GATEWAY_SSH_HOST_KEY`), a Kubernetes Secret. It is parsed in memory and never written to
disk.
- When it is missing, startup fails. The exception is `allowGeneratedHostKey` (tests and dev
only, in memory, with a warning).
- The host key fingerprint is shown on the app home.
- **Timeouts:** 15 minutes idle; auth within 30 seconds.
### 6.2 Accounts and authentication
- **Login.** The login name is `file_gateway_account.username`.
- The offered key must match, by SHA-256 fingerprint, an unrevoked and unexpired
`file_gateway_ssh_key` of that account.
- The account must be `ACTIVE` and unexpired.
- The client IP must be inside `allowed_cidrs` (empty means any; service accounts should set it).
- **Lockout.**
- 5 failed auths within 15 minutes, per username or per IP, lock that username or IP for 30
minutes. All three numbers are configurable.
- A lock sets `locked_until` and raises an alert (`file_gateway_lockout`, `QAlerts.raise`).
- The record action "Unlock account" clears it.
- Lockout state lives in the database, so it holds across pods.
- A per-IP limit on new connections (30 per minute) applies before auth.
- **Per-account limits:**
- `max_concurrent_sessions` (default 4);
- `max_operations_per_minute` (default 600);
- `max_file_size_mb` (default 100);
- `max_upload_mb_per_day` and `max_download_mb_per_day` (default unlimited).
An exceeded limit returns an SFTP failure and logs outcome `LIMIT`.
- **Revocation.** Every 60 seconds each pod re-reads the accounts that have live sessions. A
disabled or expired account, or a revoked key, ends its sessions.
### 6.3 Who can do what
| Permission | Grants |
|---|---|
| `file_gateway.connect` | Sign in over SFTP at all. Checked at login through the minted session's permissions. |
| `file_gateway.view` | The app, its home and read access to all `file_gateway_*` tables. |
| `file_gateway.admin` | Insert, edit and delete on accounts, keys and endpoints; the record actions; managing other users' keys. |
- Table permissions use the table names (`file_gateway_account.read`, and so on) under the app
permission.
- Self-service goes through Preferences only (§3.5).
### 6.4 Client channel and walls
- **Registration.** `ClientChannelsMetaData.ofOrWithNew(qInstance).withChannel("sftp", "SFTP file
gateway")`.
- **Headers.** Every invoker call sends `X-QQQ-Client: sftp/<sessionId>` and
`X-QQQ-Invoked-By: file-gateway`.
- **The `sftp` wall.** The channel is denied:
- `file_gateway_*`;
- the security surfaces of the assistant's default wall: `authentik_*`, `audit*`,
`*api_key*`, `*apikey*`, `*credential*`, `*secret*`, `*password*`, `*permission*`, the role
tables, `*user_session*` and `webhooks_*`.
- **The `assistant` channel** is denied `file_gateway_*`.
### 6.5 Pluggable layers
- `FileGatewayAuthenticatorInterface` turns a protocol credential into an account. v1 ships only
the SSH-key authenticator.
- `FileGatewayProtocolServer` (start, stop, active sessions) has one v1 implementation:
`SftpProtocolServer`.
- The virtual tree, UI API client, job pipeline and audit writer are protocol-neutral, so FTPS
(§15) would add only a server and an authenticator.
## 7. Audit
- **Connections.** Every connection writes `file_gateway_connection_log`. The row is opened at TCP
accept and updated at auth and at disconnect. It records the outcome, IP, key fingerprint, SSH
client version, byte and operation counts, and channel `sftp`.
- **Operations.** Every list, upload, download, rename and remove writes
`file_gateway_transfer_log`. It records the operation, path, object kind and id, bytes, sha256,
duration, outcome and message, with channel `sftp/<sessionId>`.
- A directory read (`opendir`) is logged once per read; `stat` is not logged.
- Denied attempts are logged with outcome `DENIED`.
- **Data changes.** Changes made by bulk loads carry `audit.clientChannel = sftp/<sessionId>` from
core. Secured-data views are logged in that qbit's own access log.
## 8. Tables
All tables are RDBMS tables in the app's backend, lowercase snake_case, with backend names equal to
the table names.
| Table | Purpose | Key fields |
|---|---|---|
| `file_gateway_account` (main) | an SFTP login | `username` (unique, `[a-z0-9._-]{3,64}`), `label`, `account_type` (USER, SERVICE), `user_id` (the QQQ user the session acts as), `owner_user_id`, `status` (ACTIVE, DISABLED, LOCKED), `root_mode` (FULL_TREE, ENDPOINTS_ONLY), `allowed_cidrs`, `expires_at`, `formats`, the limits (§6.2), `dedupe_uploads`, `notify_on_success`, `pgp_public_key`, `pgp_decrypt_uploads`, `pgp_encrypt_downloads`, `failed_login_count`, `locked_until`, `last_login_at`, `last_login_ip` |
| `file_gateway_ssh_key` (child of account) | public keys | `account_id`, `label`, `public_key` (OpenSSH text), `fingerprint` (unique `SHA256:...`), `key_type`, `key_bits`, `expires_at`, `revoked_at`, `last_used_at`, `last_used_ip` |
| `file_gateway_alias` (main, "Endpoints") | partner paths | `name` (unique slug), `path` (unique), `kind` (BULK_LOAD, INBOX, VIEW, REPORT, OUTBOX), `account_id` (null = shared), `table_name`, `saved_bulk_load_profile_id`, `saved_view_id`, `saved_report_id`, `format`, `file_name`, `consumer_name`, `is_active`, `description` |
| `file_gateway_upload_job` (main) | one uploaded file | `account_id`, `alias_id`, `connection_log_id`, `remote_address`, `table_name`, `saved_bulk_load_profile_id`, `consumer_name`, `consumer_reference`, `path`, `file_name`, `size_bytes`, `sha256`, `storage_key`, `status` (RECEIVING, QUEUED, RUNNING, SUCCEEDED, PARTIAL, FAILED, REJECTED, DUPLICATE, ABORTED), `duplicate_of_job_id`, `process_uuid`, `inserted_count`, `error_count`, `result_summary`, `results_storage_key`, `error_message`, `attempts`, `next_attempt_at`, `claimed_by`, `claimed_at`, `queued_at`, `started_at`, `finished_at`, `acknowledged_at` |
| `file_gateway_outbox_file` (child of endpoint) | files waiting for pickup | `alias_id`, `file_name`, `size_bytes`, `sha256`, `storage_key`, `status` (READY, DOWNLOADED, REMOVED, EXPIRED), `first_downloaded_at`, `download_count`, `removed_at`, `expires_at`, `created_by_consumer` |
| `file_gateway_transfer_log` (main, "Transfers") | every operation | `account_id`, `connection_log_id`, `operation` (LIST, UPLOAD, DOWNLOAD, RENAME, REMOVE), `path`, `object_kind` (APP, TABLE, PROFILE, VIEW, REPORT, RESULT, ARCHIVE, ENDPOINT, OUTBOX), `object_id`, `format`, `bytes`, `sha256`, `duration_ms`, `outcome` (OK, DENIED, NOT_FOUND, LIMIT, ERROR), `message`, `client_channel`, `upload_job_id` |
| `file_gateway_connection_log` (main, "Connections") | every connection | `account_id`, `username`, `remote_ip`, `client_version`, `key_fingerprint`, `outcome` (CONNECTED, AUTH_FAILED, LOCKED_OUT, IP_DENIED, EXPIRED, DISABLED, LIMIT), `session_id`, `pod_name`, `connected_at`, `last_seen_at`, `disconnected_at`, `disconnect_reason`, `bytes_in`, `bytes_out`, `operation_count`, `client_channel` |
- **Child lists:**
- an account shows its keys, upload jobs and connections;
- an endpoint shows its upload jobs and outbox files;
- an upload job shows its transfers.
- **Quick search and events.** Accounts (username, label), endpoints (name, path) and upload jobs
(file name) are in Quick Search and publish ESB table events. All tables are in the QQQ API.
- **Retention.** A daily scheduled job, not in the nav, applies these defaults:
- transfer and connection logs: 180 days;
- archive objects: 30 days;
- outbox files: 30 days;
- results: 90 days;
- `RECEIVING` jobs older than 1 hour become `ABORTED`, and their objects are deleted.
- **Active sessions** are connection rows with no `disconnected_at` and a `last_seen_at` within the
last 3 minutes. Pods update `last_seen_at` every minute.
- **Record actions** (standard streamed ETL, Review then Result):
- "Unlock account";
- "Disable account";
- "Revoke key";
- "Retry upload" (FAILED, REJECTED);
- "End session" (on active connections; the owning pod closes it within 60 seconds).
- **Key validation on insert.** The key is parsed and checked against the §6.1 minimums. The
fingerprint, type and bits are computed server-side. Keys are classified CONFIDENTIAL.
### App home (`file_gateway`)
Typed widgets only, with real data:
- **KPI row:** transfers today, failed jobs in the last 24 hours, active sessions, MB in and out
today, and locked accounts.
- **Recent failures:** a table of upload jobs that were FAILED, PARTIAL or REJECTED in the last 7
days, linked to their records.
- **Top partners (7 days):** a table of accounts by transfers and MB.
- **Active sessions:** a table of username, IP, connected since, operations and pod.
- **Server:** the host key fingerprint, the port, and the gateway pods seen in the last 3 minutes.
## 9. Consumer and outbox API (`com.kingsrook.qbits.filegateway.api`)
- The package is published in the `qbit-file-gateway` artifact and allow-listed under G1 for other
qbits, like object-storage's `api`.
- It holds interfaces and records only, with no runtime and no tables.
- Consumers (qbit-edi) depend on it with `<optional>true</optional>`.
- The shape was agreed with the qbit-edi agent on 2026-10-13.
```java
public interface FileGatewayConsumer
{
String getName(); // lowercase [a-z0-9-]+, e.g. "edi"
FileAcceptance accept(InboundFile file) throws Exception;
default void onDownloaded(OutboxFileRef ref) throws Exception { }
}
public record InboundFile(Integer uploadJobId, String endpointName, String username, String userId,
String remoteAddress, String path, String fileName, long sizeBytes, String sha256,
Instant receivedAt, String storageBucketAlias, String storageKey,
Supplier<InputStream> streamSupplier)
{
public InputStream openStream() { return (streamSupplier.get()); } // fresh stream from object storage each call
}
public final class FileAcceptance // ACCEPTED | REJECTED | RETRY_LATER
{
public static FileAcceptance accepted(String consumerReference);
public static FileAcceptance rejected(String reason);
public static FileAcceptance retryLater(String reason, Duration retryAfter);
}
public record OutboxFileRef(Integer outboxFileId, String endpointName, String path, String fileName,
long sizeBytes, String sha256, Instant createdAt) { }
public final class FileGatewayOutbox
{
public static boolean isAvailable();
public static OutboxFileRef put(String endpointName, String fileName, InputStream content, long sizeBytes)
throws FileGatewayUnavailableException, QException;
}
FileGatewayConsumersMetaData.ofOrWithNew(qInstance).withConsumer(new QCodeReference(EdiFileGatewayConsumer.class));
```
- **Binding is by data.**
- An INBOX endpoint row names its `consumer_name`.
- `upload/<profile>/` folders and BULK_LOAD endpoints use the built-in `bulk-load` consumer.
- **`accept` delivery.**
- It runs after close, the scan and PGP, at-least-once, on a gateway worker.
- QContext holds the account user's session, with channel `sftp`.
- Consumers keep `accept` short: store the file and return.
- RETRY_LATER, or an exception, retries after 1m, 5m, 15m, 1h and 6h. After 5 attempts the job is
FAILED and the user is notified.
- An unknown consumer gives REJECTED.
- Gateway dedupe (§4) runs first; consumers dedupe on (uploadJobId, sha256).
- **Outbox.**
- `put` streams the content to object storage, adds a `file_gateway_outbox_file` row, and the
file lists in the OUTBOX endpoint's folder.
- The first complete download calls `onDownloaded` on the endpoint's `consumer_name`, if one is
set, at-least-once.
- When the partner removes the file, it is marked REMOVED.
- `put` to a missing or inactive endpoint throws `QException`.
- The facade dispatches to the implementation that the producer registers. Without the gateway
installed, it throws `FileGatewayUnavailableException`.
## 10. Configuration (`FileGatewayQBitConfig`)
- **SFTP server:**
- `sftpEnabled` (default false; true only in the gateway deployment, from
`FILE_GATEWAY_SFTP_ENABLED`);
- `sftpPort` (2222);
- `hostKeyEnvVarName` (`FILE_GATEWAY_SSH_HOST_KEY`);
- `allowGeneratedHostKey` (false).
- **Storage and buffers:**
- `fileGatewayBucketAlias` (`file-gateway`);
- `uploadPartSizeMb` (8);
- `uploadReorderWindowMb` (64);
- `downloadReadAheadMb` (16);
- `maxGeneratedFileMb` (1024).
- **Upload jobs:**
- `uploadWorkerThreads` (4);
- `dedupeWindow` (7 days);
- `jobStaleAfter` (30 minutes);
- `temporaryUploadSuffixes`.
- **Lockout and limits.** The lockout and per-IP limits (§6.2), plus the defaults for new
accounts.
- **Optional:**
- `scanner` (a `QCodeReference`);
- `pgpPrivateKeyEnvVarName` and `pgpPassphraseEnvVarName`;
- retention (§8);
- `tableMetaDataCustomizer`.
## 11. Kubernetes
The gateway is a separate, stateless Deployment of the same image. An example is in `docs/k8s/`:
`gateway-deployment.yaml` and `gateway-service.yaml`.
- **Deployment.**
- `FILE_GATEWAY_SFTP_ENABLED=true`, plus the app's usual environment.
- `FILE_GATEWAY_SSH_HOST_KEY` from a Secret; in kof22, through ESO from Infisical.
- 2 replicas, with a PodDisruptionBudget of `minAvailable: 1`.
- `readOnlyRootFilesystem: true`. The only writable mount is a small in-memory `emptyDir`
(`medium: Memory`, `sizeLimit: 64Mi`) for the JVM temp directory. Nothing is persisted.
- Requests of 250m CPU and 768Mi memory; a 1.5Gi memory limit. The in-memory buffers are bounded
by the config.
- **Ports and probes.**
- Container ports: 2222 (sftp) and the app's HTTP port, which serves loopback and probes only.
- Readiness: `tcpSocket: 2222` and the app's HTTP health endpoint.
- Liveness: HTTP health.
- Startup: allows 2 minutes for the QInstance.
- **Service.** `type: LoadBalancer`, with these settings:
- the annotation `lbipam.cilium.io/ips: 10.100.22.65` in kof22 (owner, 2026-10-13; .60, .61 and .64 are taken);
- **port 22 → 2222 only (no passive range in v1)**;
- `externalTrafficPolicy: Local`, so the pod sees the real client IP for allowlists and
lockouts.
- **Shutdown.** On SIGTERM a pod stops accepting connections and lets sessions drain for up to 60
seconds. Then it marks its own connection rows ended and requeues its claimed jobs.
- **UniFi.** The WAN 22 (or 2222) port forward to the LB IP is an owner step, in Praetor
`unifi/` tofu.
## 12. Tests (acceptance criteria)
All tests run in the qbit's build.
- **Harness:**
- an embedded `QApplicationJavalinServer` with `withStrictUiApiRoutes(true)` (G2) on a random
port;
- an H2 RDBMS backend and a memory object-storage bucket;
- the embedded SFTP server on a random port, with an in-memory generated host key;
- the MINA SSHD client (`SftpClient`), with real keys generated per test.
- **Listing per permission.**
- Two users with different permissions see different apps, tables, and `upload/`, `views/` and
`reports/` folders.
- A hidden table answers "no such file".
- An `ENDPOINTS_ONLY` account sees only its endpoints.
- **Upload to bulk load, end to end.**
- Putting a CSV into `upload/<profile>/` creates a job, runs the saved profile as the user and
inserts the rows.
- It writes `.done` and the results file, and archives the original.
- A bad row gives `PARTIAL` with per-row errors.
- The same file again gives `DUPLICATE`.
- A `.filepart` upload followed by a rename queues once.
- `QAlerts.notify` is called.
- **Downloading a view with masking.**
- `views/<slug>.csv` returns the view's filter and columns.
- A field the user may not read comes back masked or absent, exactly as the `/qqq/v1` export
returns it.
- **Getting a report.** `reports/<slug>.csv` runs `renderSavedReport` as the user and streams the
result.
- **Auth failures.**
- These are refused, each with a connection-log outcome: an unknown key, a revoked key, an
expired key, an expired or disabled account, and an IP outside the allowlist.
- Password and keyboard-interactive are never offered.
- **Lockout.** 5 bad attempts lock the account and raise the alert. "Unlock account" clears the
lock.
- **Audit.**
- Connect, list, upload and download each write their rows, with channel `sftp/...`.
- Core audits of inserted rows carry `sftp`.
- **Limits.** The file-size cap aborts an upload. The session cap refuses the fifth session.
- **No disk (owner rule).** Every transfer test runs under a filesystem guard:
- `java.io.tmpdir` points at a fresh per-test directory;
- a snapshot check lists the working directory, `user.home` and the temp
directory before and after each test;
- the test asserts that nothing was created outside the temp area, and that the temp area is
empty after each transfer completes.
- **Consumer API.**
- A test consumer bound to an INBOX endpoint receives an `InboundFile` with the correct sha256,
`remoteAddress` and stream.
- RETRY_LATER retries.
- An outbox `put` lists in the OUTBOX folder, and the download calls `onDownloaded`.
- **Stateless restart.** Stop the server mid-session and start a new one on the same database and
bucket. Jobs requeue, lockouts persist, and the client reconnects and sees the same tree.
- **Contract.** The invoker is only ever called with spec'd operations, asserted through
`UiApiInvoker.findOperation`. The qbit registers no `/qqq/v1` extension, so G3 does not apply.
- **Screenshots.** The app home and the account, upload-job and endpoint records, in light and
dark, at 390 and 1440 px.
## 13. Guardrails (ARCHITECTURE-REVIEW-2026-10-13)
- **G1.** The only qbit dependency is `qbit-object-storage` (its `api` package). There is no
`Class.forName` of another qbit. The gateway's own `api` package is the published contract.
- **G2.** Server tests run in strict mode.
- **G3.** No extension is registered: the gateway is a UI API client.
- **G4.** Stand-ins are marked `STAND-IN(core=..., removeBy=...)`: the 4.1.0-SNAPSHOT baseline
until RC.6, and the preferences section until #1162.
- **G5.** No HTML widgets.
- **G7.** Pin 4.1.0-RC.6 as soon as it is cut. Until then, develop builds on the SNAPSHOT.
## 14. Phases
- **(a)** The SFTP server, the virtual filesystem, and auth:
- keys, accounts, lockout, CIDR, expiry and limits;
- listing;
- connection and transfer logs;
- the tables;
- the `api` package (types only);
- the core PR (§3.1–3.3).
- **(b)** Upload to bulk load:
- streaming to object storage, sha256 and dedupe;
- the job worker, sidecars, results and archive;
- notifications;
- the consumer pipeline and the scan hook.
- **(c)** Downloads of saved views and saved reports, with read-ahead and download limits.
- **(d)** *Removed* (FTPS; see §15).
- **(e)** The rest, then the drafts:
- endpoints (aliases) and chroot;
- the outbox;
- PGP;
- the Preferences section;
- the app home;
- retention;
- then the kof22 adoption (draft) and the cluster-gitops draft.
## 15. FTPS (future, not in v1)
FTPS is added only if a partner needs it. It would be a second `FileGatewayProtocolServer`, using
Apache FtpServer, over the same virtual tree, UI API client, jobs and audit:
- **TLS.** Explicit (AUTH TLS, port 21) and/or implicit (990), TLS 1.2 or later, and never plain
FTP.
- **Authentication.** Either or both of:
- revocable per-account **app passwords**: hashed, shown once, with an expiry;
- **client certificates**: the certificate's fingerprint pinned on the account.
- **Networking.** A **passive port range** (for example 30000–30049) on the LoadBalancer, with
`externalTrafficPolicy: Local` and the passive address set to the LB IP.
- **Tests.** Protocol tests with the Commons Net `FTPSClient`.
## 16. Owner steps
- The LoadBalancer IP is 10.100.22.65 (decided 2026-10-13); reserve it in the cluster-gitops PR.
- Create the host key secret (`FILE_GATEWAY_SSH_HOST_KEY`) in Infisical.
- Add the UniFi port forward.
- Grant `file_gateway.connect` to the roles that may use SFTP, and `file_gateway.admin` to admins.
- Merge the qbit-authentik automated-session PR (§3.4) before go-live.
Vendored Executable
+295
View File
@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
+189
View File
@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+168 -38
View File
@@ -1,37 +1,107 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!--
~ QQQ - Low-code Application Framework for Engineers.
~ Copyright (C) 2021-2026. Kingsrook, LLC
~ 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
~ contact@kingsrook.com
~ https://github.com/Kingsrook/
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-example-app</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>QBit Example Application</name>
<description>Template for Application QBits - complete mini-applications with UI navigation</description>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<qqq.version>4.0.0</qqq.version>
<jacoco.version>0.8.15</jacoco.version>
<h2.version>2.2.224</h2.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-file-gateway</artifactId>
<version>${revision}</version>
<packaging>jar</packaging>
<name>QBit File Gateway</name>
<description>QBit that serves QQQ applications as an SFTP folder tree: upload files into saved bulk-load profiles, download saved views and saved reports, with per-user SSH keys and a full transfer audit</description>
<url>https://github.com/QRun-IO/qbit-file-gateway</url>
<modelVersion>4.0.0</modelVersion>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
<distribution>repo</distribution>
</license>
</licenses>
<developers>
<developer>
<id>kingsrook</id>
<name>Kingsrook Team</name>
<email>contact@kingsrook.com</email>
<organization>Kingsrook, LLC</organization>
<organizationUrl>https://kingsrook.com</organizationUrl>
</developer>
</developers>
<scm>
<connection>scm:git:git://github.com/QRun-IO/qbit-file-gateway.git</connection>
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-file-gateway.git</developerConnection>
<url>https://github.com/QRun-IO/qbit-file-gateway</url>
<tag>HEAD</tag>
</scm>
<dependencies>
<!-- QQQ Backend Core -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
<version>${qqq.version}</version>
</dependency>
<!-- Testing -->
<!-- The memory backend is included in qqq-backend-core. -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.14</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>${h2.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -40,51 +110,111 @@
<version>3.27.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
<version>${qqq.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.2.224</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.3.1</version>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>10.12.6</version>
</dependency>
</dependencies>
<configuration>
<configLocation>checkstyle/config.xml</configLocation>
<headerLocation>checkstyle/license.txt</headerLocation>
<encoding>UTF-8</encoding>
<consoleOutput>true</consoleOutput>
<failsOnError>false</failsOnError>
<failOnViolation>true</failOnViolation>
<violationSeverity>warning</violationSeverity>
<includeTestSourceDirectory>false</includeTestSourceDirectory>
</configuration>
<executions>
<execution>
<id>validate</id>
<phase>validate</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.10</version>
<version>${jacoco.version}</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
<limit>
<counter>CLASS</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<!-- QQQ 4.1 APIs until qbit-build-parent imports the 4.1 BOM (as in qbit-quick-search and qbit-cdn, ADR-0007). -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<!-- STAND-IN(core=QRun-IO/qqq develop after v4.1.0-RC.5, removeBy=4.1.0-RC.6): UiApiInvoker client channels and QAlerts.notify are on develop only; pin the RC when it is cut -->
<qqq.snapshot.version>4.1.0-SNAPSHOT</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.snapshot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</profile>
</profiles>
</project>
@@ -1,164 +0,0 @@
/*******************************************************************************
** Configuration class for the Example Application QBit.
**
** Application QBits support:
** - Backend configuration for data storage
** - Optional table prefixing for multi-instance deployment
** - Feature toggles for optional modules
** - Security key prefix for permission scoping
** - Integration hooks for other QBits
*******************************************************************************/
package com.kingsrook.qbits.example;
import java.util.List;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
public class ExampleAppQBitConfig implements QBitConfig
{
private String backendName;
private String tableNamePrefix;
private Boolean enableChildModule = true;
private String securityKeyPrefix;
/////////////////////////////////////////////////////////////////////////
// Integration hooks - allow host app to inject custom behavior //
/////////////////////////////////////////////////////////////////////////
private QCodeReference onEntityCreatedHandler;
private QCodeReference customValidationProvider;
/*******************************************************************************
** Validate the configuration before the QBit is produced.
*******************************************************************************/
@Override
public void validate(QInstance qInstance, List<String> errors)
{
if(!StringUtils.hasContent(backendName))
{
errors.add("backendName is required for ExampleAppQBit");
}
if(qInstance.getBackend(backendName) == null)
{
errors.add("Backend not found: " + backendName);
}
}
/*******************************************************************************
** Apply prefix to a table name if configured.
*******************************************************************************/
public String applyPrefix(String tableName)
{
if(StringUtils.hasContent(tableNamePrefix))
{
return tableNamePrefix + "_" + tableName;
}
return tableName;
}
/*******************************************************************************
** Get the security key for permission checks.
*******************************************************************************/
public String getSecurityKey(String baseName)
{
if(StringUtils.hasContent(securityKeyPrefix))
{
return securityKeyPrefix + "." + baseName;
}
return baseName;
}
//////////////////////////////////////////////////////////////////////////////
// Getters and fluent setters //
//////////////////////////////////////////////////////////////////////////////
public String getBackendName()
{
return backendName;
}
public ExampleAppQBitConfig withBackendName(String backendName)
{
this.backendName = backendName;
return this;
}
public String getTableNamePrefix()
{
return tableNamePrefix;
}
public ExampleAppQBitConfig withTableNamePrefix(String tableNamePrefix)
{
this.tableNamePrefix = tableNamePrefix;
return this;
}
public Boolean getEnableChildModule()
{
return enableChildModule;
}
public ExampleAppQBitConfig withEnableChildModule(Boolean enableChildModule)
{
this.enableChildModule = enableChildModule;
return this;
}
public String getSecurityKeyPrefix()
{
return securityKeyPrefix;
}
public ExampleAppQBitConfig withSecurityKeyPrefix(String securityKeyPrefix)
{
this.securityKeyPrefix = securityKeyPrefix;
return this;
}
public QCodeReference getOnEntityCreatedHandler()
{
return onEntityCreatedHandler;
}
public ExampleAppQBitConfig withOnEntityCreatedHandler(QCodeReference onEntityCreatedHandler)
{
this.onEntityCreatedHandler = onEntityCreatedHandler;
return this;
}
public QCodeReference getCustomValidationProvider()
{
return customValidationProvider;
}
public ExampleAppQBitConfig withCustomValidationProvider(QCodeReference customValidationProvider)
{
this.customValidationProvider = customValidationProvider;
return this;
}
}
@@ -1,175 +0,0 @@
/*******************************************************************************
** Producer for the Example Application QBit.
**
** Application QBits produce:
** - QBitMetaData for identification
** - QAppSection for UI navigation (REQUIRED)
** - Tables, processes, widgets, and other metadata
** - Security configuration
*******************************************************************************/
package com.kingsrook.qbits.example;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerHelper;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProducer;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.SourceQBitAware;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qbits.example.model.ExampleEntity;
import com.kingsrook.qbits.example.model.ExampleChildEntity;
public class ExampleAppQBitProducer implements QBitProducer
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-example-app";
public static final String VERSION = "0.1.0";
private ExampleAppQBitConfig config;
/*******************************************************************************
** Produce the QBit metadata into the QInstance.
*******************************************************************************/
@Override
public void produce(QInstance qInstance, String namespace) throws QException
{
/////////////////////////////////////////////////////////////////////////
// Validate configuration //
/////////////////////////////////////////////////////////////////////////
List<String> errors = new ArrayList<>();
config.validate(qInstance, errors);
if(!errors.isEmpty())
{
throw new QException("Configuration errors: " + String.join(", ", errors));
}
/////////////////////////////////////////////////////////////////////////
// Create and register QBit identity //
/////////////////////////////////////////////////////////////////////////
QBitMetaData qBitMetaData = new QBitMetaData()
.withGroupId(GROUP_ID)
.withArtifactId(ARTIFACT_ID)
.withVersion(VERSION)
.withNamespace(namespace)
.withConfig(config);
qInstance.addQBit(qBitMetaData);
/////////////////////////////////////////////////////////////////////////
// Discover and produce all component metadata //
/////////////////////////////////////////////////////////////////////////
List<MetaDataProducerInterface<?>> producers =
MetaDataProducerHelper.findProducers(getClass().getPackageName());
for(MetaDataProducerInterface<?> producer : producers)
{
///////////////////////////////////////////////////////////////////////
// Skip disabled components //
///////////////////////////////////////////////////////////////////////
if(!isProducerEnabled(producer))
{
continue;
}
MetaDataProducerOutput output = producer.produce(qInstance);
///////////////////////////////////////////////////////////////////////
// Apply table prefix if configured //
///////////////////////////////////////////////////////////////////////
if(output instanceof QTableMetaData table)
{
String prefixedName = config.applyPrefix(table.getName());
table.setName(prefixedName);
table.setBackendName(config.getBackendName());
}
///////////////////////////////////////////////////////////////////////
// Mark scope for all SourceQBitAware outputs //
///////////////////////////////////////////////////////////////////////
if(output instanceof SourceQBitAware sqa)
{
sqa.setSourceQBitName(qBitMetaData.getName());
}
output.addSelfToInstance(qInstance);
}
/////////////////////////////////////////////////////////////////////////
// Register QAppMetaData for UI navigation (REQUIRED for app QBits) //
/////////////////////////////////////////////////////////////////////////
qInstance.addApp(produceApp(config, qBitMetaData.getName()));
}
/*******************************************************************************
** Produce the QAppMetaData for UI navigation.
**
** This is REQUIRED for Application QBits - it defines how users navigate
** to the tables, processes, and widgets in the UI.
*******************************************************************************/
private QAppMetaData produceApp(ExampleAppQBitConfig config, String qbitName)
{
List<String> tables = new ArrayList<>();
tables.add(config.applyPrefix(ExampleEntity.TABLE_NAME));
if(Boolean.TRUE.equals(config.getEnableChildModule()))
{
tables.add(config.applyPrefix(ExampleChildEntity.TABLE_NAME));
}
QAppSection section = new QAppSection()
.withName(config.applyPrefix("exampleSection"))
.withLabel("Example Application")
.withIcon(new QIcon().withName("dashboard"))
.withTables(tables);
return new QAppMetaData()
.withName(config.applyPrefix("exampleApp"))
.withLabel("Example Application")
.withIcon(new QIcon().withName("dashboard"))
.withSection(section);
}
/*******************************************************************************
** Check if a producer should be enabled based on configuration.
*******************************************************************************/
private boolean isProducerEnabled(MetaDataProducerInterface<?> producer)
{
/////////////////////////////////////////////////////////////////////////
// Skip child entity if module disabled //
/////////////////////////////////////////////////////////////////////////
if(!Boolean.TRUE.equals(config.getEnableChildModule()))
{
if(producer.getClass().getSimpleName().contains("ExampleChildEntity"))
{
return false;
}
}
return true;
}
//////////////////////////////////////////////////////////////////////////////
// Fluent setters //
//////////////////////////////////////////////////////////////////////////////
public ExampleAppQBitProducer withConfig(ExampleAppQBitConfig config)
{
this.config = config;
return this;
}
}
@@ -1,159 +0,0 @@
/*******************************************************************************
** Example child entity demonstrating parent-child relationships.
**
** This entity shows:
** - Foreign key relationship to parent entity
** - Conditional inclusion via config toggle
*******************************************************************************/
package com.kingsrook.qbits.example.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
@QMetaDataProducingEntity(producePossibleValueSource = true)
public class ExampleChildEntity extends QRecordEntity
{
public static final String TABLE_NAME = "exampleChildEntity";
@QField(isPrimaryKey = true)
private Integer id;
@QField(isRequired = true, possibleValueSourceName = "exampleEntity")
private Integer exampleEntityId;
@QField(isRequired = true, maxLength = 100)
private String name;
@QField(maxLength = 500)
private String description;
@QField
private Integer sortOrder;
@QField
private Boolean isActive = true;
@QField
private Instant createDate;
@QField
private Instant modifyDate;
//////////////////////////////////////////////////////////////////////////////
// Fluent setters //
//////////////////////////////////////////////////////////////////////////////
public ExampleChildEntity withId(Integer id)
{
this.id = id;
return this;
}
public ExampleChildEntity withExampleEntityId(Integer exampleEntityId)
{
this.exampleEntityId = exampleEntityId;
return this;
}
public ExampleChildEntity withName(String name)
{
this.name = name;
return this;
}
public ExampleChildEntity withDescription(String description)
{
this.description = description;
return this;
}
public ExampleChildEntity withSortOrder(Integer sortOrder)
{
this.sortOrder = sortOrder;
return this;
}
public ExampleChildEntity withIsActive(Boolean isActive)
{
this.isActive = isActive;
return this;
}
public ExampleChildEntity withCreateDate(Instant createDate)
{
this.createDate = createDate;
return this;
}
public ExampleChildEntity withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return this;
}
//////////////////////////////////////////////////////////////////////////////
// Getters //
//////////////////////////////////////////////////////////////////////////////
public Integer getId()
{
return id;
}
public Integer getExampleEntityId()
{
return exampleEntityId;
}
public String getName()
{
return name;
}
public String getDescription()
{
return description;
}
public Integer getSortOrder()
{
return sortOrder;
}
public Boolean getIsActive()
{
return isActive;
}
public Instant getCreateDate()
{
return createDate;
}
public Instant getModifyDate()
{
return modifyDate;
}
}
@@ -1,145 +0,0 @@
/*******************************************************************************
** Example entity for an Application QBit.
**
** This entity demonstrates:
** - Standard QQQ entity patterns
** - PossibleValueSource generation
** - Fields with various types and constraints
*******************************************************************************/
package com.kingsrook.qbits.example.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity;
@QMetaDataProducingEntity(producePossibleValueSource = true)
public class ExampleEntity extends QRecordEntity
{
public static final String TABLE_NAME = "exampleEntity";
@QField(isPrimaryKey = true)
private Integer id;
@QField(isRequired = true, maxLength = 100)
private String name;
@QField(maxLength = 500)
private String description;
@QField(label = "Status")
private String status;
@QField
private Boolean isActive = true;
@QField
private Instant createDate;
@QField
private Instant modifyDate;
//////////////////////////////////////////////////////////////////////////////
// Fluent setters //
//////////////////////////////////////////////////////////////////////////////
public ExampleEntity withId(Integer id)
{
this.id = id;
return this;
}
public ExampleEntity withName(String name)
{
this.name = name;
return this;
}
public ExampleEntity withDescription(String description)
{
this.description = description;
return this;
}
public ExampleEntity withStatus(String status)
{
this.status = status;
return this;
}
public ExampleEntity withIsActive(Boolean isActive)
{
this.isActive = isActive;
return this;
}
public ExampleEntity withCreateDate(Instant createDate)
{
this.createDate = createDate;
return this;
}
public ExampleEntity withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return this;
}
//////////////////////////////////////////////////////////////////////////////
// Getters //
//////////////////////////////////////////////////////////////////////////////
public Integer getId()
{
return id;
}
public String getName()
{
return name;
}
public String getDescription()
{
return description;
}
public String getStatus()
{
return status;
}
public Boolean getIsActive()
{
return isActive;
}
public Instant getCreateDate()
{
return createDate;
}
public Instant getModifyDate()
{
return modifyDate;
}
}
@@ -1,46 +0,0 @@
/*******************************************************************************
** MetaData producer for the example process.
*******************************************************************************/
package com.kingsrook.qbits.example.processes;
import java.util.List;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qbits.example.model.ExampleEntity;
public class ExampleProcessMetaDataProducer implements MetaDataProducerInterface<QProcessMetaData>
{
public static final String NAME = "exampleProcess";
/*******************************************************************************
** Produce the process metadata.
*******************************************************************************/
@Override
public QProcessMetaData produce(QInstance qInstance) throws QException
{
return new QProcessMetaData()
.withName(NAME)
.withLabel("Example Process")
.withTableName(ExampleEntity.TABLE_NAME)
.withStepList(List.of(
new QBackendStepMetaData()
.withName("process")
.withCode(new QCodeReference(ExampleProcessStep.class))
.withInputData(new QFunctionInputMetaData()
.withField(new QFieldMetaData("confirmAction", QFieldType.BOOLEAN)
.withLabel("Confirm Action")
.withDefaultValue(false)))
));
}
}
@@ -1,67 +0,0 @@
/*******************************************************************************
** Example process step demonstrating business logic implementation.
**
** This shows:
** - Standard process step patterns
** - Input/output handling
** - Logging conventions
*******************************************************************************/
package com.kingsrook.qbits.example.processes;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
public class ExampleProcessStep extends AbstractTransformStep
{
private static final QLogger LOG = QLogger.getLogger(ExampleProcessStep.class);
/*******************************************************************************
** Run the process step.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
List<QRecord> records = input.getRecords();
LOG.info("Processing records", logPair("count", records.size()));
for(QRecord record : records)
{
///////////////////////////////////////////////////////////////////////
// Example business logic - update status //
///////////////////////////////////////////////////////////////////////
String currentStatus = record.getValueString("status");
if("PENDING".equals(currentStatus))
{
record.setValue("status", "PROCESSED");
}
output.addRecord(record);
}
LOG.info("Processing complete", logPair("processed", records.size()));
}
/*******************************************************************************
** Return the process summary. Override this method in subclasses to provide
** meaningful summary lines for the result screen.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput runBackendStepOutput, boolean isForResultScreen)
{
return (new ArrayList<>());
}
}
@@ -1,35 +0,0 @@
/*******************************************************************************
** MetaData producer for an example dashboard widget.
**
** Widgets provide dashboard components for Application QBits.
*******************************************************************************/
package com.kingsrook.qbits.example.widgets;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerInterface;
public class ExampleDashboardWidgetMetaDataProducer implements MetaDataProducerInterface<QWidgetMetaData>
{
public static final String NAME = "exampleDashboardWidget";
/*******************************************************************************
** Produce the widget metadata.
*******************************************************************************/
@Override
public QWidgetMetaData produce(QInstance qInstance) throws QException
{
return new QWidgetMetaData()
.withName(NAME)
.withLabel("Example Dashboard")
.withType(WidgetType.STATISTICS.getType())
.withCodeReference(new QCodeReference(ExampleDashboardWidgetRenderer.class));
}
}
@@ -1,33 +0,0 @@
/*******************************************************************************
** Renderer for the example dashboard widget.
**
** This class produces the data displayed in the widget.
*******************************************************************************/
package com.kingsrook.qbits.example.widgets;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
public class ExampleDashboardWidgetRenderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Render the widget data.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
/////////////////////////////////////////////////////////////////////////
// Add chart data, counts, or other dashboard information here //
/////////////////////////////////////////////////////////////////////////
StatisticsData data = new StatisticsData()
.withCount(0)
.withCountContext("Total items");
return (new RenderWidgetOutput(data));
}
}
@@ -0,0 +1,101 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.filegateway;
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Configuration for the file-gateway qbit.
*******************************************************************************/
public class FileGatewayQBitConfig implements QBitConfig
{
private MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer;
private boolean sftpEnabled = false;
private int sftpPort = 2222;
/*******************************************************************************
** Getter for the optional table customizer.
*******************************************************************************/
@Override
public MetaDataCustomizerInterface<QTableMetaData> getTableMetaDataCustomizer()
{
return (tableMetaDataCustomizer);
}
/*******************************************************************************
** Fluent setter for the optional table customizer.
*******************************************************************************/
public FileGatewayQBitConfig withTableMetaDataCustomizer(MetaDataCustomizerInterface<QTableMetaData> tableMetaDataCustomizer)
{
this.tableMetaDataCustomizer = tableMetaDataCustomizer;
return (this);
}
/*******************************************************************************
** Whether this process runs the SFTP server (only the gateway deployment does).
*******************************************************************************/
public boolean getSftpEnabled()
{
return (sftpEnabled);
}
/*******************************************************************************
** Fluent setter for whether this process runs the SFTP server.
*******************************************************************************/
public FileGatewayQBitConfig withSftpEnabled(boolean sftpEnabled)
{
this.sftpEnabled = sftpEnabled;
return (this);
}
/*******************************************************************************
** Getter for the SFTP listen port (default 2222).
*******************************************************************************/
public int getSftpPort()
{
return (sftpPort);
}
/*******************************************************************************
** Fluent setter for the SFTP listen port.
*******************************************************************************/
public FileGatewayQBitConfig withSftpPort(int sftpPort)
{
this.sftpPort = sftpPort;
return (this);
}
}
@@ -0,0 +1,120 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.filegateway;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
/*******************************************************************************
** Entry point for the file-gateway qbit; no tables are produced yet.
*******************************************************************************/
public class FileGatewayQBitProducer implements QBitMetaDataProducer<FileGatewayQBitConfig>
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-file-gateway";
public static final String VERSION = "0.1.0";
private FileGatewayQBitConfig qBitConfig;
private String namespace;
/*******************************************************************************
** Fluent setter for the config.
*******************************************************************************/
public FileGatewayQBitProducer withQBitConfig(FileGatewayQBitConfig config)
{
this.qBitConfig = config;
return (this);
}
/*******************************************************************************
** Getter for the config.
*******************************************************************************/
@Override
public FileGatewayQBitConfig getQBitConfig()
{
return (qBitConfig);
}
/*******************************************************************************
** Fluent setter for the namespace.
*******************************************************************************/
public FileGatewayQBitProducer withNamespace(String namespace)
{
this.namespace = namespace;
return (this);
}
/*******************************************************************************
** Getter for the namespace.
*******************************************************************************/
@Override
public String getNamespace()
{
return (namespace);
}
/*******************************************************************************
** Identity and configuration of this qbit.
*******************************************************************************/
@Override
public QBitMetaData getQBitMetaData()
{
return (new QBitMetaData().withGroupId(GROUP_ID).withArtifactId(ARTIFACT_ID).withVersion(VERSION).withNamespace(namespace).withConfig(qBitConfig));
}
/*******************************************************************************
** Package-scanned producers have no config: skip them.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (qBitConfig != null);
}
/*******************************************************************************
** Register this qbit and return its metadata, without producing tables.
*******************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance qInstance)
{
QBitMetaData qBit = getQBitMetaData();
qInstance.addQBit(qBit);
return (new MetaDataProducerMultiOutput().with(qBit));
}
}
@@ -0,0 +1,93 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.filegateway;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Verify the scaffold's registration and configuration contract.
*******************************************************************************/
class FileGatewayQBitProducerTest
{
/*******************************************************************************
** Produce the identity and exact config into an otherwise empty instance.
*******************************************************************************/
@Test
void producesQBitWithoutTables()
{
QInstance instance = new QInstance();
FileGatewayQBitConfig config = new FileGatewayQBitConfig();
FileGatewayQBitProducer producer = new FileGatewayQBitProducer().withQBitConfig(config);
MetaDataProducerMultiOutput output = producer.produce(instance);
QBitMetaData qBit = instance.getQBits().get("com.kingsrook.qbits:qbit-file-gateway");
assertThat(qBit).isNotNull();
assertThat(qBit.getGroupId()).isEqualTo("com.kingsrook.qbits");
assertThat(qBit.getArtifactId()).isEqualTo("qbit-file-gateway");
assertThat(qBit.getVersion()).isEqualTo("0.1.0");
assertThat(qBit.getConfig()).isSameAs(config);
assertThat(producer.getQBitConfig()).isSameAs(config);
assertThat(producer.isEnabled()).isTrue();
assertThat(producer.getNamespace()).isNull();
assertThat(output.getEach(QBitMetaData.class)).containsExactly(qBit);
assertThat(output.getEach(QTableMetaData.class)).isEmpty();
assertThat(instance.getTables()).isEmpty();
assertThat(config.getSftpEnabled()).isFalse();
assertThat(config.getSftpPort()).isEqualTo(2222);
assertThat(config.getTableMetaDataCustomizer()).isNull();
}
/*******************************************************************************
** Configured instances may be namespaced; unconfigured scanning is disabled.
*******************************************************************************/
@Test
void supportsNamespacedConfiguration()
{
assertThat(new FileGatewayQBitProducer().isEnabled()).isFalse();
MetaDataCustomizerInterface<QTableMetaData> customizer = (instance, table) -> table;
FileGatewayQBitConfig config = new FileGatewayQBitConfig()
.withTableMetaDataCustomizer(customizer)
.withSftpEnabled(true)
.withSftpPort(2022);
FileGatewayQBitProducer producer = new FileGatewayQBitProducer()
.withQBitConfig(config)
.withNamespace("test");
QInstance instance = new QInstance();
producer.produce(instance);
assertThat(producer.getNamespace()).isEqualTo("test");
assertThat(instance.getQBits().get("com.kingsrook.qbits:qbit-file-gateway:test").getConfig()).isSameAs(config);
assertThat(config.getTableMetaDataCustomizer()).isSameAs(customizer);
assertThat(config.getSftpEnabled()).isTrue();
assertThat(config.getSftpPort()).isEqualTo(2022);
assertThat(instance.getTables()).isEmpty();
}
}