From 24be00bafc58294221bf937ab0a1b86fde689c51 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 10:59:38 -0500 Subject: [PATCH 1/2] build: scaffold per the QRun qbit repository standard Coordinates com.kingsrook.qbits:qbit-slack, qbit-build-parent 2.1.1 with guardrails enforced, qqq-orb 0.6.9, QQQ 4.1.0-RC.6, Checkstyle and the JaCoCo gate. Minimal SlackQBitProducer and SlackQBitConfig with a test. Co-Authored-By: Claude Opus 5.5 --- .circleci/config.yml | 72 ++++- .editorconfig | 20 ++ .gitattributes | 3 + .github/dependabot.yml | 25 ++ .github/release.yml | 31 ++ .github/workflows/release-rc.yml | 25 ++ .gitignore | 31 +- .mvn/wrapper/maven-wrapper.properties | 3 + AGENTS.md | 49 +++ CHANGELOG.md | 11 + CLAUDE.md | 52 ++- CODE_OF_CONDUCT.md | 3 + CODE_STYLE.md | 3 + CONTRIBUTING.md | 6 + LICENSE | 2 +- NOTICE | 6 +- README.md | 62 ++-- SECURITY.md | 4 + checkstyle/config.xml | 246 +++++++++++++++ checkstyle/license.txt | 19 ++ docs/00-getting-started.md | 28 -- docs/01-extension-patterns.md | 16 - mvnw | 295 ++++++++++++++++++ mvnw.cmd | 189 +++++++++++ pom.xml | 201 ++++++++++-- .../example/ExampleExtensionQBitConfig.java | 75 ----- .../example/ExampleExtensionQBitProducer.java | 66 ---- .../customizers/ExampleTableCustomizer.java | 23 -- .../qbits/slack/SlackQBitConfig.java | 125 ++++++++ .../qbits/slack/SlackQBitProducer.java | 98 ++++++ .../qbits/slack/SlackQBitProducerTest.java | 74 +++++ 31 files changed, 1562 insertions(+), 301 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/release.yml create mode 100644 .github/workflows/release-rc.yml create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CODE_STYLE.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 checkstyle/config.xml create mode 100644 checkstyle/license.txt delete mode 100644 docs/00-getting-started.md delete mode 100644 docs/01-extension-patterns.md create mode 100755 mvnw create mode 100644 mvnw.cmd delete mode 100644 src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitConfig.java delete mode 100644 src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitProducer.java delete mode 100644 src/main/java/com/kingsrook/qbits/example/customizers/ExampleTableCustomizer.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java create mode 100644 src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java create mode 100644 src/test/java/com/kingsrook/qbits/slack/SlackQBitProducerTest.java diff --git a/.circleci/config.yml b/.circleci/config.yml index 010cd62..14dd169 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,21 +1,65 @@ version: 2.1 orbs: - qqq-orb: kingsrook/qqq-orb@0.5.0 + qqq-orb: kingsrook/qqq-orb@0.6.9 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+$/ ] diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8db2889 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ddf5f61 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.cmd text eol=crlf +mvnw text eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9878878 --- /dev/null +++ b/.github/dependabot.yml @@ -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: + patterns: ['*'] diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..e78ff07 --- /dev/null +++ b/.github/release.yml @@ -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: + - "*" diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml new file mode 100644 index 0000000..dd332a0 --- /dev/null +++ b/.github/workflows/release-rc.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 9a52950..a971850 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,21 @@ -# Maven target/ +.env +.idea +*.iml +*.class +*.log *.jar *.war +*.nar *.ear - -# IDE -.idea/ -*.iml -.vscode/ -.project -.classpath -.settings/ - -# OS +*.zip +*.tar.gz +*.rar +hs_err_pid* .DS_Store -Thumbs.db - -# Logs -*.log +*.sw* +.flattened-pom.xml +dependency-reduced-pom.xml +/.env.local +/.cache/ +.claude/settings.local.json diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..c0bcafe --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d8de406 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# AGENTS.md + +This file provides guidance to coding agents when working with code in this repository. + +## Project Overview + +**QBit Slack**: Slack for a QQQ system, with per-user and per-tenant connections, a core messaging +provider and delivery destination, a durable outbox, templates, readiness and PHI-safe payloads. +Design: `docs/DESIGN.md`. + +## Build Commands + +```bash +./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM +./mvnw test +./mvnw verify # tests + Checkstyle + guardrails + JaCoCo gate (70% instructions, 90% classes) +``` + +## Architecture + +- **Entry point:** `SlackQBitProducer implements QBitMetaDataProducer`, package `com.kingsrook.qbits.slack`. +- `api` is the only public package (mirrors qbit-email's `EmailApi`). Everything else is internal. + +## Invariants + +- Every message has an owner (a QQQ user) and is authorized as that owner when queued and again when sent. +- A message only ever uses its own workspace row's or connection row's token. There is no fallback. +- Tokens are encrypted, write-only and HIDDEN; they never reach a log, an exception, an audit or an API. +- Every send goes through the outbox (`slack_message`); nothing posts inline. +- Classified PHI and PII never enter a Slack payload. + +## Naming (owner rule) + +- Tables are lowercase snake_case, prefixed `slack_`; the backend name is the table name. +- Permissions use the full qbit name: `slack.view`, `slack.connect`, `slack.send`, `slack.admin`. +- Data lives in standard visible tables. Processes are table or record actions, never in the nav. +- UI goes through the `/qqq/v1` UI API only; Slack callbacks are under `/qqq/hooks/slack/...`. + +## Code Style + +3-space indent, braces on the next line, `return (x);`, flower-box Javadoc on every class and method, fluent `withX()` setters. + +## Release + +- CircleCI qqq-orb 0.6.9. +- `develop` publishes SNAPSHOTs. +- `release/*` publishes RCs (automated by `.github/workflows/release-rc.yml`). +- `vX.Y.Z` tags publish releases (GA tags only). +- Never push `hotfix/*` or `release/*` branches by hand; use `feature/` or `fix/`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e3870ec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project are documented in this file. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Design (`docs/DESIGN.md`) and the Slack app manifest (`docs/slack-app-manifest.yaml`). +- Repository scaffold per the QRun qbit repository standard: `SlackQBitProducer` and `SlackQBitConfig`. diff --git a/CLAUDE.md b/CLAUDE.md index e9917d3..d10209a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,17 +1,49 @@ -# qbit-template-extension +# CLAUDE.md -GitHub template repository for scaffolding QQQ "Extension QBits" (infrastructure-level plugins: table customizers, auth, audit, action wrappers). Not a shipping library — consumers click "Use this template" and rename the `com.kingsrook.qbits.example` package. +This file provides guidance to Claude Code when working with code in this repository. -## QQQ 4.0 baseline — 2026-09-24 +## Project 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 Slack**: Slack for a QQQ system, with per-user and per-tenant connections, a core messaging +provider and delivery destination, a durable outbox, templates, readiness and PHI-safe payloads. +Design: `docs/DESIGN.md`. -## Knowledge base +## Build Commands -Reviewed dossiers for this repo and the wider QQQ platform live in the second-brain vault: +```bash +./mvnw compile # always use the wrapper: Maven 3.10 cannot read the parent POM +./mvnw test +./mvnw verify # tests + Checkstyle + guardrails + JaCoCo gate (70% instructions, 90% classes) +``` -- Hub / entry point: `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-extension.md` -- QBit mechanics refresher: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/architecture/metadata-model.md` +## Architecture -Reviewed at commit `e7d4b4d672cc` (branch `main`, 2026-05-21). Known drift as of that review: no `qbit-build-parent` parent pom (org convention), qqq pinned at 0.35.0 on main (0.40.0-SNAPSHOT on develop), README still claims AGPL-3.0 while LICENSE/NOTICE are Apache-2.0, and README references `scripts/customize_template.py` / `actions/ExampleActionCustomizer.java` which do not exist. See the dossier's "Maturity & risks" and "v4.0 impact" sections before modifying the scaffold. +- **Entry point:** `SlackQBitProducer implements QBitMetaDataProducer`, package `com.kingsrook.qbits.slack`. +- `api` is the only public package (mirrors qbit-email's `EmailApi`). Everything else is internal. + +## Invariants + +- Every message has an owner (a QQQ user) and is authorized as that owner when queued and again when sent. +- A message only ever uses its own workspace row's or connection row's token. There is no fallback. +- Tokens are encrypted, write-only and HIDDEN; they never reach a log, an exception, an audit or an API. +- Every send goes through the outbox (`slack_message`); nothing posts inline. +- Classified PHI and PII never enter a Slack payload. + +## Naming (owner rule) + +- Tables are lowercase snake_case, prefixed `slack_`; the backend name is the table name. +- Permissions use the full qbit name: `slack.view`, `slack.connect`, `slack.send`, `slack.admin`. +- Data lives in standard visible tables. Processes are table or record actions, never in the nav. +- UI goes through the `/qqq/v1` UI API only; Slack callbacks are under `/qqq/hooks/slack/...`. + +## Code Style + +3-space indent, braces on the next line, `return (x);`, flower-box Javadoc on every class and method, fluent `withX()` setters. + +## Release + +- CircleCI qqq-orb 0.6.9. +- `develop` publishes SNAPSHOTs. +- `release/*` publishes RCs (automated by `.github/workflows/release-rc.yml`). +- `vX.Y.Z` tags publish releases (GA tags only). +- Never push `hotfix/*` or `release/*` branches by hand; use `feature/` or `fix/`. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..29084f7 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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) diff --git a/CODE_STYLE.md b/CODE_STYLE.md new file mode 100644 index 0000000..0e6fef2 --- /dev/null +++ b/CODE_STYLE.md @@ -0,0 +1,3 @@ +# Code Style Guide + +* See Project [Code Style Guide](https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ff761f1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,6 @@ +# Contributing to QBit Webhooks + +* 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). diff --git a/LICENSE b/LICENSE index b3f61de..4bb040e 100644 --- a/LICENSE +++ b/LICENSE @@ -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. diff --git a/NOTICE b/NOTICE index 29ff097..86edf0b 100644 --- a/NOTICE +++ b/NOTICE @@ -1,6 +1,6 @@ -qBit Template - Extension -Copyright 2022-2025 QRunIO, LLC +QBit Slack +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. diff --git a/README.md b/README.md index 4b03f15..5f69912 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,48 @@ -# QBit Template: Extension +# QBit Slack -Template repository for creating QQQ Extension QBits - infrastructure extensions, customizers, and action handlers. +Slack for a QQQ system. Users connect their own Slack accounts, and organization admins install the +app for their own workspace. The qbit registers a core messaging provider (`slack`) and a delivery +destination, so any process, scheduled job, report or export can send to a Slack DM or channel through +core alone. Every message is authorized as its owning user at send time, so the qbit is safe for +deployments with many tenants. Delivery uses a durable outbox with Slack rate-limit handling, and +classified PHI and PII never reach Slack. -## What is an Extension QBit? +Design: [docs/DESIGN.md](docs/DESIGN.md). -Extension QBits extend or enhance how QQQ operates at the infrastructure/framework level. They provide: -- New backend implementations -- Authentication mechanisms -- Audit capabilities -- Table customizers -- Action handlers +## Requirements -## Quick Start +- Java 21 +- QQQ 4.1 (currently built against 4.1.0-RC.6) +- A Slack app created from [docs/slack-app-manifest.yaml](docs/slack-app-manifest.yaml) -Requires **Java 21**, **Maven 3.8+**, and QQQ **4.0.0**. Create your repository, customize its Maven coordinates and Java package/classes, implement the table customizer, then run `mvn clean verify`. See [Getting Started](docs/00-getting-started.md). +## Install -## Structure - -``` -src/main/java/com/kingsrook/qbits/example/ -├── ExampleExtensionQBitConfig.java # Configuration -├── ExampleExtensionQBitProducer.java # Entry point -└── customizers/ - └── ExampleTableCustomizer.java # Table customization +```java +new SlackQBitProducer() + .withQBitConfig(new SlackQBitConfig() + .withBackendName("rdbms") + .withSchedulerName("scheduler")) + .produce(qInstance); ``` -## Key Characteristics +## Tables, permissions and configuration -- **No QAppSection** - Pure infrastructure -- **No user-facing tables** (or minimal operational) -- **Wraps existing tables/processes** with new behavior -- **Validates dependencies** on other QQQ components +See [docs/DESIGN.md](docs/DESIGN.md), sections 3 to 14. The tables are prefixed `slack_` and the +permissions `slack.`. Secrets are environment-variable names only: `SLACK_CLIENT_ID`, +`SLACK_CLIENT_SECRET`, `SLACK_SIGNING_SECRET` and `SLACK_TOKEN_KEY`. -## Documentation +## Adopting in another app -- [Getting Started](docs/00-getting-started.md) -- [Extension Patterns](docs/01-extension-patterns.md) +Send with core `SendMessageAction` and the provider name `slack`, or deliver output to the `slack` +destination. Don't import this qbit's internal packages; `com.kingsrook.qbits.slack.api` is the only +public surface. + +## Build + +```bash +./mvnw verify +``` ## License -See [LICENSE](LICENSE) and [NOTICE](NOTICE). +Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b937592 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/checkstyle/config.xml b/checkstyle/config.xml new file mode 100644 index 0000000..385d653 --- /dev/null +++ b/checkstyle/config.xml @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/checkstyle/license.txt b/checkstyle/license.txt new file mode 100644 index 0000000..92332b5 --- /dev/null +++ b/checkstyle/license.txt @@ -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. + */ diff --git a/docs/00-getting-started.md b/docs/00-getting-started.md deleted file mode 100644 index fe1538c..0000000 --- a/docs/00-getting-started.md +++ /dev/null @@ -1,28 +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 ExampleExtensionQBitProducer() - .withConfig(new ExampleExtensionQBitConfig() - .withTargetTableName("order")) - .produce(qInstance, "my-extension"); -``` - -Register the target table before producing the extension, then implement its customizer. See [Extension Patterns](01-extension-patterns.md). diff --git a/docs/01-extension-patterns.md b/docs/01-extension-patterns.md deleted file mode 100644 index 55ccff7..0000000 --- a/docs/01-extension-patterns.md +++ /dev/null @@ -1,16 +0,0 @@ -# Extension Patterns - -[ExampleExtensionQBitProducer](../src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitProducer.java) registers the QBit identity and calls `applyTableCustomizations` for the configured target. That method is the extension point: look up the table, validate that it exists, and call your customized [ExampleTableCustomizer](../src/main/java/com/kingsrook/qbits/example/customizers/ExampleTableCustomizer.java). - -```java -QTableMetaData table = qInstance.getTable(tableName); -if(table == null) -{ - throw new QException("Target table not found: " + tableName); -} -new ExampleTableCustomizer().customize(table); -``` - -When implementing this method, add `throws QException` to its declaration. Run the config's `validate(QInstance, List)` before registering metadata, and report validation errors rather than silently ignoring a missing target. The uncustomized template contains placeholders for this behavior. - -For record hooks, use QQQ's existing table customizer interfaces or `AbstractPreInsertCustomizer`. Its `apply(List)` method returns the records for the next stage; register the customizer through the table's `TableCustomizers.PRE_INSERT_RECORD` role. Check the actual QQQ interface before adding another hook. Add tests proving that the intended table changes and invalid configurations are handled. diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -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-,maven-mvnd--}/ +[ -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 "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/mvnw.cmd @@ -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-,maven-mvnd--}/ +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" diff --git a/pom.xml b/pom.xml index 56a062c..3053fa4 100644 --- a/pom.xml +++ b/pom.xml @@ -1,36 +1,98 @@ - - 4.0.0 + - com.kingsrook.qbits - qbit-example-extension - 0.1.0-SNAPSHOT - jar - - QBit Example Extension - Example Extension QBit - rename this for your QBit + + 0.1.0-SNAPSHOT 21 21 - UTF-8 - 4.0.0 + 0.8.15 + 2.2.224 + true + true + + com.kingsrook + qbit-build-parent + 2.1.1 + + + com.kingsrook.qbits + qbit-slack + ${revision} + jar + + QBit Slack + QBit for Slack across a QQQ system: per-user and per-tenant Slack connections, a core messaging provider and delivery destination, a durable outbox with rate-limit handling, Block Kit templates, readiness and PHI-safe payloads + https://github.com/QRun-IO/qbit-slack + + 4.0.0 + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + repo + + + + + + kingsrook + Kingsrook Team + contact@kingsrook.com + Kingsrook, LLC + https://kingsrook.com + + + + + scm:git:git://github.com/QRun-IO/qbit-slack.git + scm:git:ssh://github.com:QRun-IO/qbit-slack.git + https://github.com/QRun-IO/qbit-slack + HEAD + + com.kingsrook.qqq qqq-backend-core - ${qqq.version} + + com.kingsrook.qqq + qqq-backend-module-rdbms + test + + + com.h2database + h2 + ${h2.version} + test + org.junit.jupiter junit-jupiter - 5.10.0 test @@ -45,14 +107,109 @@ org.apache.maven.plugins - maven-compiler-plugin - 3.11.0 + maven-checkstyle-plugin + 3.3.1 + + + com.puppycrawl.tools + checkstyle + 10.12.6 + + + + checkstyle/config.xml + checkstyle/license.txt + UTF-8 + true + false + true + warning + true + + + + validate + validate + + check + + + - org.apache.maven.plugins - maven-surefire-plugin - 3.1.2 + org.jacoco + jacoco-maven-plugin + ${jacoco.version} + + + com/kingsrook/qbits/slack/demo/** + + + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + check + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.70 + + + CLASS + COVEREDRATIO + 0.90 + + + + + + + - \ No newline at end of file + + + + + qqq-snapshot + + true + + + 4.1.0-RC.6 + + + + + com.kingsrook.qqq + qqq-bom-pom + ${qqq.snapshot.version} + pom + import + + + + + + diff --git a/src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitConfig.java b/src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitConfig.java deleted file mode 100644 index e0e3c9b..0000000 --- a/src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitConfig.java +++ /dev/null @@ -1,75 +0,0 @@ -/******************************************************************************* - ** Configuration for the Example Extension QBit. - *******************************************************************************/ -package com.kingsrook.qbits.example; - -import java.util.List; - -import com.kingsrook.qqq.backend.core.model.metadata.QInstance; -import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig; - - -/******************************************************************************* - ** Configuration class for the Example Extension QBit. - *******************************************************************************/ -public class ExampleExtensionQBitConfig implements QBitConfig -{ - private String targetTableName; - private Boolean enableFeatureX = true; - - - - /******************************************************************************* - ** Validate configuration before QBit is produced. - *******************************************************************************/ - @Override - public void validate(QInstance qInstance, List errors) - { - if(targetTableName != null && qInstance.getTable(targetTableName) == null) - { - errors.add("Target table not found: " + targetTableName); - } - } - - - - /******************************************************************************* - ** Getter for targetTableName - *******************************************************************************/ - public String getTargetTableName() - { - return targetTableName; - } - - - - /******************************************************************************* - ** Fluent setter for targetTableName - *******************************************************************************/ - public ExampleExtensionQBitConfig withTargetTableName(String targetTableName) - { - this.targetTableName = targetTableName; - return this; - } - - - - /******************************************************************************* - ** Getter for enableFeatureX - *******************************************************************************/ - public Boolean getEnableFeatureX() - { - return enableFeatureX; - } - - - - /******************************************************************************* - ** Fluent setter for enableFeatureX - *******************************************************************************/ - public ExampleExtensionQBitConfig withEnableFeatureX(Boolean enableFeatureX) - { - this.enableFeatureX = enableFeatureX; - return this; - } -} diff --git a/src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitProducer.java b/src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitProducer.java deleted file mode 100644 index ea5a1b9..0000000 --- a/src/main/java/com/kingsrook/qbits/example/ExampleExtensionQBitProducer.java +++ /dev/null @@ -1,66 +0,0 @@ -/******************************************************************************* - ** Producer for the Example Extension QBit. - *******************************************************************************/ -package com.kingsrook.qbits.example; - -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.qbits.QBitMetaData; -import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProducer; - - -/******************************************************************************* - ** Producer class for the Example Extension QBit. - *******************************************************************************/ -public class ExampleExtensionQBitProducer implements QBitProducer -{ - public static final String GROUP_ID = "com.kingsrook.qbits"; - public static final String ARTIFACT_ID = "qbit-example-extension"; - public static final String VERSION = "0.1.0"; - - private ExampleExtensionQBitConfig config; - - - - /******************************************************************************* - ** Produce this QBit into the given QInstance. - *******************************************************************************/ - @Override - public void produce(QInstance qInstance, String namespace) throws QException - { - QBitMetaData qBitMetaData = new QBitMetaData() - .withGroupId(GROUP_ID) - .withArtifactId(ARTIFACT_ID) - .withVersion(VERSION) - .withNamespace(namespace) - .withConfig(config); - - qInstance.addQBit(qBitMetaData); - - if(config != null && config.getTargetTableName() != null) - { - applyTableCustomizations(qInstance, config.getTargetTableName()); - } - } - - - - /******************************************************************************* - ** Apply customizations to the target table. - *******************************************************************************/ - private void applyTableCustomizations(QInstance qInstance, String tableName) - { - // Implement your extension logic here - } - - - - /******************************************************************************* - ** Fluent setter for config - *******************************************************************************/ - public ExampleExtensionQBitProducer withConfig(ExampleExtensionQBitConfig config) - { - this.config = config; - return this; - } -} diff --git a/src/main/java/com/kingsrook/qbits/example/customizers/ExampleTableCustomizer.java b/src/main/java/com/kingsrook/qbits/example/customizers/ExampleTableCustomizer.java deleted file mode 100644 index 0e7ec48..0000000 --- a/src/main/java/com/kingsrook/qbits/example/customizers/ExampleTableCustomizer.java +++ /dev/null @@ -1,23 +0,0 @@ -/******************************************************************************* - ** Example table customizer. - *******************************************************************************/ -package com.kingsrook.qbits.example.customizers; - -import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; - - -/******************************************************************************* - ** Example table customizer that adds behavior to existing tables. - *******************************************************************************/ -public class ExampleTableCustomizer -{ - - /******************************************************************************* - ** Apply customizations to the given table. - *******************************************************************************/ - public void customize(QTableMetaData table) - { - // Example: Add audit tracking fields - // table.withField(new QFieldMetaData("createdAt", QFieldType.DATE_TIME)); - } -} diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java new file mode 100644 index 0000000..c3f5aab --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitConfig.java @@ -0,0 +1,125 @@ +/* + * 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.slack; + + +import java.util.List; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig; + + +/******************************************************************************* + ** Configuration for the Slack qbit (docs/DESIGN.md section 14.2). Settings are + ** added phase by phase; secrets are only ever environment-variable names. + *******************************************************************************/ +public class SlackQBitConfig implements QBitConfig +{ + private String backendName; + private String schedulerName; + private String bridgeProviderName = "slack"; + + + + /******************************************************************************* + ** Check required settings. + *******************************************************************************/ + @Override + public void validate(QInstance qInstance, List errors) + { + assertCondition(backendName != null && !backendName.isBlank(), "Slack qbit: backendName is required", errors); + assertCondition(schedulerName != null && !schedulerName.isBlank(), "Slack qbit: schedulerName is required", errors); + assertCondition(bridgeProviderName != null && bridgeProviderName.matches("[a-zA-Z][a-zA-Z0-9_]*"), "Slack qbit: bridgeProviderName must be a simple name", errors); + } + + + + /******************************************************************************* + ** Tables default to the configured backend. + *******************************************************************************/ + @Override + public String getDefaultBackendNameForTables() + { + return (backendName); + } + + + + /******************************************************************************* + ** Getter for backendName + *******************************************************************************/ + public String getBackendName() + { + return (this.backendName); + } + + + + /******************************************************************************* + ** Fluent setter for backendName + *******************************************************************************/ + public SlackQBitConfig withBackendName(String backendName) + { + this.backendName = backendName; + return (this); + } + + + + /******************************************************************************* + ** Getter for schedulerName + *******************************************************************************/ + public String getSchedulerName() + { + return (this.schedulerName); + } + + + + /******************************************************************************* + ** Fluent setter for schedulerName + *******************************************************************************/ + public SlackQBitConfig withSchedulerName(String schedulerName) + { + this.schedulerName = schedulerName; + return (this); + } + + + + /******************************************************************************* + ** Getter for bridgeProviderName + *******************************************************************************/ + public String getBridgeProviderName() + { + return (this.bridgeProviderName); + } + + + + /******************************************************************************* + ** Fluent setter for bridgeProviderName + *******************************************************************************/ + public SlackQBitConfig withBridgeProviderName(String bridgeProviderName) + { + this.bridgeProviderName = bridgeProviderName; + return (this); + } +} diff --git a/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java new file mode 100644 index 0000000..156893c --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/slack/SlackQBitProducer.java @@ -0,0 +1,98 @@ +/* + * 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.slack; + + +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer; + + +/******************************************************************************* + ** Entry point: produce the Slack qbit into a QInstance. + *******************************************************************************/ +public class SlackQBitProducer implements QBitMetaDataProducer +{ + public static final String GROUP_ID = "com.kingsrook.qbits"; + public static final String ARTIFACT_ID = "qbit-slack"; + public static final String VERSION = "0.1.0"; + + private SlackQBitConfig qBitConfig; + private String namespace; + + + + /******************************************************************************* + ** Fluent setter for the config. + *******************************************************************************/ + public SlackQBitProducer withQBitConfig(SlackQBitConfig config) + { + this.qBitConfig = config; + return (this); + } + + + + /******************************************************************************* + ** Fluent setter for the namespace. + *******************************************************************************/ + public SlackQBitProducer withNamespace(String namespace) + { + this.namespace = namespace; + return (this); + } + + + + /******************************************************************************* + ** The qbit identity. + *******************************************************************************/ + @Override + public QBitMetaData getQBitMetaData() + { + return (new QBitMetaData() + .withGroupId(GROUP_ID) + .withArtifactId(ARTIFACT_ID) + .withVersion(VERSION) + .withNamespace(namespace)); + } + + + + /******************************************************************************* + ** The config. + *******************************************************************************/ + @Override + public SlackQBitConfig getQBitConfig() + { + return (qBitConfig); + } + + + + /******************************************************************************* + ** The namespace. + *******************************************************************************/ + @Override + public String getNamespace() + { + return (namespace); + } +} diff --git a/src/test/java/com/kingsrook/qbits/slack/SlackQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/slack/SlackQBitProducerTest.java new file mode 100644 index 0000000..6632b41 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/slack/SlackQBitProducerTest.java @@ -0,0 +1,74 @@ +/* + * 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.slack; + + +import java.util.ArrayList; +import java.util.List; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Unit test for SlackQBitProducer and SlackQBitConfig. + *******************************************************************************/ +class SlackQBitProducerTest +{ + + /******************************************************************************* + ** The producer reports its identity and config. + *******************************************************************************/ + @Test + void testIdentity() + { + SlackQBitConfig config = new SlackQBitConfig().withBackendName("rdbms").withSchedulerName("scheduler"); + SlackQBitProducer producer = new SlackQBitProducer().withQBitConfig(config).withNamespace("ns"); + + QBitMetaData metaData = producer.getQBitMetaData(); + assertThat(metaData.getArtifactId()).isEqualTo("qbit-slack"); + assertThat(metaData.getGroupId()).isEqualTo("com.kingsrook.qbits"); + assertThat(producer.getNamespace()).isEqualTo("ns"); + assertThat(producer.getQBitConfig()).isSameAs(config); + assertThat(config.getDefaultBackendNameForTables()).isEqualTo("rdbms"); + assertThat(config.getBackendName()).isEqualTo("rdbms"); + assertThat(config.getSchedulerName()).isEqualTo("scheduler"); + assertThat(config.getBridgeProviderName()).isEqualTo("slack"); + } + + + + /******************************************************************************* + ** Required settings are validated. + *******************************************************************************/ + @Test + void testValidate() + { + List errors = new ArrayList<>(); + new SlackQBitConfig().withBridgeProviderName("not ok").validate(new QInstance(), errors); + assertThat(errors).hasSize(3); + + errors.clear(); + new SlackQBitConfig().withBackendName("b").withSchedulerName("s").validate(new QInstance(), errors); + assertThat(errors).isEmpty(); + } +} -- 2.54.0 From cae5b88fa1ae736f14fa71f710fd3ef752d43b61 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 14 Oct 2026 10:59:39 -0500 Subject: [PATCH 2/2] docs: design for qbit-slack (per-user, multi-tenant, delivery destinations) Owner decisions (2026-10-14): all recommendations accepted (standing approval). Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN.md | 766 +++++++++++++++++++++++++++++++++++ docs/slack-app-manifest.yaml | 41 ++ 2 files changed, 807 insertions(+) create mode 100644 docs/DESIGN.md create mode 100644 docs/slack-app-manifest.yaml diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..87c5803 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,766 @@ +# qbit-slack design + +**Status:** approved design, built in phases (section 15). + +## Owner requirements (verbatim) + +- 2026-10-14: "like we have an email qbit, we need one for slack too, so other processes etc can send + messages to users via email or via slack etc etc" +- 2026-10-14: "we should be able to send messages to channels etc as output from scheduled jobs and scheduled + reports etc and from running things, etc or even as exports from the system (download is an option, but + so is slack, etc). Slack should be tied to the user logged in, as with some deployments, we have users + from MANY different companies logged in at once" + +## Owner decisions (2026-10-14): all recommendations accepted (standing approval) + +The owner approved all open designs on 2026-10-12 ("they are approved. we will adjust as needed once we +can view them working"). Every recommendation below is decided as written. The hard requirements from the +coordinator brief of 2026-10-14 are written into this design as requirements, not options (section 1). + +**Superseded decision.** On 2026-10-13, qbit-alerts `docs/NOTIFICATIONS.md` section 1.1 recorded "Slack +stays a group or monitor channel only, never per user." The 2026-10-14 requirements replace it: Slack is a +per-user channel and a delivery destination. Alert-group and monitor Slack channels (incoming webhooks in +qbit-alerts) keep working unchanged. + +## 0. Summary + +qbit-slack connects QQQ to Slack **per user and per tenant**. + +- **Connections are per user.** Each user connects their own Slack account through Slack OAuth ("Connect + Slack"). An organization admin may install the app's bot into their own workspace, and that install is + scoped to their organization (tenant) in QQQ. Tokens are encrypted at rest, write-only, and never shown, + logged or returned. +- **Messages are posted for a QQQ user (the owner).** + - They use that user's own connection, or their tenant's bot. + - They go only to workspaces the owner connected, and to channels the owner is a member of. Membership + is checked at send time. + - There is never a fallback to another user's or tenant's token. +- **Core messaging provider** (type `QBIT_SLACK`, name `slack`). Any process sends with core + `SendMessageAction`, so callers depend only on core. +- **Delivery destination** `slack`, through the new core output-delivery SPI (section 9). Process results, + durable jobs, scheduled reports and jobs, exports, saved views and assistant scheduled tasks can deliver + to a Slack DM or channel, next to Download and Email. Large outputs are uploaded as files, or sent as a + link. +- **Durable outbox** (`slack_message`). It retries, honours Slack rate limits (`Retry-After`), and + dedupes by idempotency key. Message metadata reconciliation stops a crashed runner from posting twice. +- **Readiness and health.** A public API package `SlackApi` mirrors `EmailApi`, and the qbit registers a + health check. +- **Notifications.** A Slack column in each user's notification preferences, so `QAlerts.notify(...)` can + reach them in Slack (section 10). +- **PHI.** Slack is not HIPAA-eligible by default, so classified PHI and PII are blocked or left out of + every Slack payload and export, with a clear message (section 11). +- **Phase 2: interactivity.** Buttons such as Approve and Acknowledge run QQQ actions as the clicking + user, with permissions enforced. + +## 1. Hard requirements + +| # | Requirement | +|---|---| +| R1 | Slack is tied to the signed-in user. Every outgoing message has an **owner** (a QQQ user id), and is authorized as that owner at send time. Ownerless sends exist only for system notices to a registered channel of a tenant's workspace (section 6.2). | +| R2 | Multi-tenant safe. Users of many companies share one deployment. Workspace installs belong to exactly one tenant (a QQQ security key value). Cross-tenant posting is impossible by construction, and is tested with two tenants and two workspaces. | +| R3 | A user posts only to workspaces they connected, and to channels they are a member of. At send time `conversations.members` (or `users.conversations`) must include the owner's Slack user id. | +| R4 | Scheduled and background deliveries run as the owning user, with that user's connection and permissions. A revoked or missing connection fails the delivery and alerts the owner. Delivery never falls back to another connection, user or tenant. | +| R5 | Tokens are encrypted (AES-256-GCM, bound to their row), write-only, never displayed, logged, audited or returned by any API. | +| R6 | Channel pickers show only what the current user's connection can see, as contingent dropdowns: workspace, then channel (or "Direct message to me"). | +| R7 | Classified PHI and PII are redacted or blocked for Slack, exports included. The user sees which fields and why. | +| R8 | Output delivery is a core concept with a thin SPI. Slack, Email and Download are destinations; object storage and SFTP come later. | + +## 2. Flow + +``` +caller ─ SendMessageAction("slack") ─┐ +caller ─ SlackApi.send / sendOnce ───┤ +DeliverOutputAction ("slack") ───────┼─> SlackService (as the OWNER's session) +QAlerts.notify ─> qbit-alerts ───────┘ 1. resolve workspace under owner's tenant lock + 2. resolve connection (owner) + target; membership check + 3. render, PHI guard + 4. insert slack_message (QUEUED, ownerUserId, workspaceId) +SlackDispatcher (scheduled) ─ claim + lease ─ re-authorize as owner ─ decrypt that row's token + └─ chat.postMessage / chat.update / files upload (with message metadata) + ok -> SENT · 429 -> wait Retry-After · transient -> RETRY_WAIT · permanent -> DEAD_LETTER + revoked/not a member -> FAILED_AUTH + QAlerts.notify(owner) (never another token) +``` + +## 3. Workspaces, connections and secrets + +### 3.1 `slack_workspace` (Workspaces) + +There is one row per Slack workspace install **per tenant**, unique (`teamId`, `tenantKey`). Two tenants that +use the same Slack workspace get two rows, each locked to its own tenant. + +| Field | Notes | +|---|---| +| `teamId`, `teamName`, `enterpriseId`, `appId` | From `oauth.v2.access` | +| `tenantKey` | The tenant security value. It's the host's configured security field (for example `clientId` or `organizationId`), applied through `SlackQBitConfig.securityFields` and `recordSecurityLocks`, the same as qbit-email. Required when the host declares a tenant lock | +| `installedByUserId`, `installedAt` | | +| `botUserId`, `botScopes` | | +| `botToken` | Encrypted (`v1::`). It's write-only, and its tier is HIDDEN for every role (core field access). Set only by the OAuth callback; it can't be edited | +| `botTokenFingerprint` | The first 8 hex characters of SHA-256, for support ("ends …"). Not reversible | +| `status` | ACTIVE / REVOKED / UNINSTALLED / ERROR | +| `readinessStatus`, `readinessCheckedAt`, `readinessSummary` | Section 8 | +| `health`, `consecutiveFailures`, `ratePerMinute` | Section 5.3 | + +Only the tenant's admins (`slack.admin`, within their tenant lock) see or change it. + +### 3.2 `slack_connection` (Connections) + +There is one row per QQQ user and workspace row, unique (`userId`, `workspaceId`). The row is owner-locked on +`userId`. Admins see their tenant's rows read-only, without tokens, and can revoke them. + +| Field | Notes | +|---|---| +| `userId` | The QQQ user (owner lock) | +| `workspaceId` | The tenant-scoped workspace row | +| `slackUserId`, `slackUserName` | From OAuth (`authed_user.id`). This is the verified identity, so there's no email matching | +| `userToken` | Encrypted user token (`xoxp-`), present when user scopes were granted. Write-only and HIDDEN | +| `userTokenFingerprint`, `userScopes` | | +| `status` | ACTIVE / REVOKED / ERROR | +| `connectedAt`, `lastVerifiedAt`, `revokedAt`, `lastError` | `lastError` is scrubbed | + +### 3.3 Connect Slack (OAuth v2) + +1. **Start** (UI API only). It's a preference-section action, **Connect Slack**, in the user's Slack + section (core #1162), or the same action on the Connections table. + - It returns the Slack authorize URL. + - The `state` is a server-signed, single-use token over the user id, tenant key, nonce, mode + (`user` or `user+bot`) and a 10-minute expiry. + - Users who hold `slack.workspace.install` in their tenant may also request the bot scopes. +2. **Callback.** `GET /qqq/hooks/slack/oauth/callback` is a qbit route through the route extension point, + like qbit-email's webhooks. It is a browser redirect from Slack. + - It requires a valid signed-in session whose user and tenant equal the `state`'s. + - It exchanges the code with `oauth.v2.access`, using the app's client id and secret from named + environment variables. + - It upserts the tenant's `slack_workspace` (bot token, when granted) and the user's `slack_connection` + (user token and Slack user id). + - Then it redirects to the Preferences page with a result notice. + - Errors redirect with a plain message, never token text. +3. **Disconnect** calls `auth.revoke` on the user token, then marks the row REVOKED and clears the token. + **Uninstall** (admin) calls `apps.uninstall` or revokes the bot token, then marks the workspace + UNINSTALLED. +4. **Revocation from Slack** (phase 2, Events API): `tokens_revoked` and `app_uninstalled` mark the rows. + On any send, `invalid_auth`, `token_revoked` or `account_inactive` also mark them. + +### 3.4 Token encryption + +`SlackTokenCipher` is AES-256-GCM, with a random 96-bit nonce per value. The associated data is +`table:rowId:userId|tenantKey`, so a ciphertext copied to another row (or another tenant's row) doesn't +decrypt. The stored format is `v1::`. + +- **Keys** come from named environment variables, never from the database: `SLACK_TOKEN_KEY` (base64, 32 + bytes), plus optional previous keys (`SLACK_TOKEN_KEY_PREVIOUS`) for rotation. This is the same model as + core `RefreshTokenCipher`. +- **Decryption** happens in the dispatcher immediately before the HTTP call. The plaintext is kept only on + the stack, and is never in a log, an exception, a table, an audit or an API response. +- **Scrubbing.** `SlackRedaction.scrub` removes `xox[abeprs]-…`, `Authorization` headers and secrets from + every error text. +- **Test.** A known fake token is driven through every failure path. The test asserts that it never reaches + a log line, a `lastError`, an exception message or an API projection. + +App-level secrets are named environment variables, allow-listed in `allowedEnvNames`: +`SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, `SLACK_SIGNING_SECRET` and `SLACK_TOKEN_KEY`. + +### 3.5 Slack app + +`docs/slack-app-manifest.yaml` defines one distributable app, used by every tenant. + +| Kind | Scopes | +|---|---| +| Bot | `chat:write`, `im:write`, `channels:read`, `groups:read`, `users:read`, `files:write`, `usergroups:read` (optional), `chat:write.customize` (optional) | +| User | `chat:write`, `channels:read`, `groups:read`, `im:write`, `files:write`, `users:read` | + +- **Redirect URL:** `https:///qqq/hooks/slack/oauth/callback`. +- **Interactivity URL (phase 2):** `https:///qqq/hooks/slack/interactions`. +- **Distribution:** public distribution is on when tenants use their own workspaces. For a single company, + it's a normal workspace app. + +## 4. Sending + +### 4.1 Who posts, and the authorization check (R1-R4) + +Every send carries `ownerUserId`. If it's missing, it's the current session user. System sessions must name +an owner, or use an ownerless channel (6.2). + +**At queue time**, under the owner's session: the session is built with `createAutomatedSessionForUser` +when the caller is a system or job context, and the owner must equal the session user. Then: + +1. Load the workspace **through a normal tenant-locked query** under the owner's session. A row of another + tenant is invisible, so it's REFUSED as "This Slack workspace isn't available to you". +2. Load the owner's ACTIVE connection in that workspace. If there isn't one, it's REFUSED with "Connect Slack + to ". +3. Resolve the target: + - **DM to the owner:** use the owner's Slack user id. + - **Channel:** check that the owner's Slack user id is in `conversations.members` for the channel, using + the workspace bot token, or the owner's user token when there's no bot. A result is cached for 10 + minutes. If not, it's REFUSED with "You aren't a member of #x". + - **DM to another QQQ user** (system notices, 4.2): only when that user has an ACTIVE connection **in + the same workspace row**. +4. Pick the **posting identity**: + - **BOT** (the tenant's bot) when the workspace has an ACTIVE bot that's a channel member, or for DMs. + - **USER** (posts as the owner, with the owner's user token) otherwise, or when the caller asks for + `postAs(USER)`. + - There is never a different row's token. + +**At send time** the dispatcher repeats steps 1 to 4 under a fresh session for the owner, because access may +have changed since the message was queued. Then it decrypts **only** the token of the chosen row. + +- **Owner lost access:** the outcome is FAILED_AUTH, and the owner is notified through `QAlerts.notify` with + category SYSTEM: "Your Slack delivery to #x failed: . Reconnect Slack." The alert + `slack.delivery.unauthorized` is raised, deduplicated per owner per day. +- **The test** uses tenants A and B with workspaces WA and WB, a fake Slack server and a different token + for each. It checks all of these: + - A cannot target WB or WB's channels: the address, the picker, the API and the destination all refuse. + - A scheduled delivery of A, after A's connection is revoked, fails with an alert to A. + - The fake server never sees WB's token on A's traffic. + - Copying A's ciphertext into B's row fails to decrypt. + - A tenant admin of B cannot see A's connections or messages. + +### 4.2 Core messaging provider (low coupling) + +`SlackOutboxMessagingProvider implements MessagingProviderInterface`, type `QBIT_SLACK`, is registered under +`bridgeProviderName` (default `slack`). + +| Address (`Party.address`) | Target | +|---|---| +| `me` | DM to the owner | +| `user:` | DM to that user through **their** connection in a workspace of the owner's tenant. Used for notifications, where owner and recipient are the same user (section 10) | +| `channel:/` | A channel the owner is a member of (pickers produce this) | +| `channel:` or `#` | A registered channel (6.2) of the owner's tenant | +| `group:` | A registered user group: a post with a mention, in its channel | + +There is no addressing by email, and no raw Slack ids without a workspace. `MultiParty` gives one outbox row +per recipient. An unknown or unauthorized target is REFUSED with a reason, never an exception. + +- **Capabilities** (core #1194): `TEMPLATES`, `IDEMPOTENCY`, `CATEGORIES`, `LINKED_RECORDS`, `ASYNC_DELIVERY` + and `MESSAGE_LOOKUP`. +- **Output:** `QUEUED` with the id, `DUPLICATE` with the earlier id, or `REFUSED` or `SUPPRESSED` with reasons. +- **Content:** `subject` becomes the bold first line and the notification text. Content items are mrkdwn, + escaped. A Block Kit JSON body needs `slack.send.blocks`. A template wins. +- **Dependency:** QQQ 4.1.0-RC.6 has the plain provider SPI. #1194's rich fields arrive in RC.7. + - Until then the bridge maps the RC.6 fields, and `SlackApi` carries the rest. + - The gap is marked `STAND-IN(core=#1194, removeBy=RC.8)`. + +### 4.3 Public API `com.kingsrook.qbits.slack.api` (mirrors `EmailApi`) + +This is the only public surface. It's additive within a 0.x minor line. + +| Method | Meaning | +|---|---| +| `isInstalled(QInstance)` | The qbit was produced | +| `send(SlackApiRequest)` | Queue: `SlackApiResult(status, messageId, reasons)` | +| `sendOnce(SlackApiRequest, String effectKey)` | One durable handoff keyed by the effect key. It refuses a caller transaction and returns `SlackApiEffectResult(QUEUED_OR_DUPLICATE or REFUSED, message)` after commit. This is the jobs_effect primitive | +| `reply(Integer parentMessageId, SlackApiRequest)` | A thread reply, which waits for the parent's `ts`. The same owner is required | +| `update(Integer messageId, SlackApiRequest)` | `chat.update` of a sent message. The same owner, or `slack.admin` in the tenant | +| `getMessage(id)`, `getMessageByIdempotencyKey(key)`, `findMessagesByIdempotencyKeyPrefix(prefix, limit)` | The metadata projection, never the body | +| `readiness(QInstance)` | `SlackApiReadiness(ready, checks)` for the deployment (app secrets and key) | +| `readinessFor(String userId)` | That user's view: connected workspaces, token health and scopes | +| `connectionsFor(String userId)` | `List` (workspace label, team, status). No tokens | +| `listTargets(String userId, Integer workspaceId)` | The channels that user can post to (picker data, R6) | +| `ensureTemplate(SlackApiTemplate)` | Seed a template once | + +`SlackApiRequest` fields: + +- `owner(userId)` and `to(...)`, using the 4.2 forms; +- `text`, `templateName`, `variables`, `idempotencyKey`, `category`; +- `linkedRecord(table, id)`, `senderName`, `threadOf(messageId)`; +- `postAs(BOT|USER)`, `attachment(...)` (9.3), `unfurl` (default off); +- `actions` (phase 2), `withTransaction(transaction)`. + +### 4.4 Permissions + +| Permission | Lets the holder | +|---|---| +| `slack.view` | Use the app and its tables, scoped by tenant and owner locks | +| `slack.connect` | Connect their own Slack (granted to ordinary users by default) | +| `slack.workspace.install` | Install the bot for their tenant | +| `slack.admin` | Administer workspaces, registered channels, groups and templates, and revoke connections, in their own tenant | +| `slack.send` | Queue through the provider, API or destination for themselves. Automated sessions for an owner use the owner's permissions | +| `slack.send.blocks` | Send raw Block Kit | +| `slack.message.body` | See message text | + +## 5. Outbox and delivery + +### 5.1 `slack_message` (Messages) + +Each message is owner-locked on `ownerUserId` and tenant-locked through the workspace. + +| Field | Notes | +|---|---| +| `ownerUserId`, `workspaceId`, `connectionId`, `postAs` | The authorization context (4.1) | +| `operation` | POST / UPDATE / FILE | +| `targetType`, `targetRef`, `slackChannelId`, `channelLabel` | `targetType`: ME, USER, CHANNEL, REGISTERED_CHANNEL, USER_GROUP | +| `status` | QUEUED, WAITING_PARENT, SENDING, RETRY_WAIT, SENT, DEAD_LETTER, FAILED_AUTH, REFUSED, SUPPRESSED, CANCELLED | +| `category`, `templateName`, `text`, `blocksJson`, `variablesSnapshot` | Rendered at queue time. `text` is masked without `slack.message.body` | +| `idempotencyKey` | Unique when present, up to 500 characters | +| `parentMessageId`, `threadTs`, `targetMessageId` | Threads and updates | +| `attachmentStorageRef`, `attachmentName`, `attachmentBytes`, `slackFileId` | For FILE messages | +| `slackTs`, `permalink` | | +| `attemptCount`, `nextAttemptDate`, `leaseExpiresDate`, `lastErrorCode`, `lastError`, `sentDate`, `deadLetterDate` | `lastError` is scrubbed | +| `linkedTableName`, `linkedRecordId`, `senderName`, `deliveryLogId` | `deliveryLogId` links to core `delivery_log` (9.2) | + +The child `slack_message_attempt` (`messageId` + `attemptNumber` unique; outcome, error code, HTTP status, +`retryAfterSeconds`, the posting identity BOT or USER, claimedBy, and dates) shows as a relation. + +### 5.2 Claim, lease and exactly-once posting + +This is the qbit-email and qbit-webhooks pattern. + +- **Claim and lease.** A runner inserts (`messageId`, *n*) into `slack_message_attempt` to claim attempt *n*. + The pair is unique, so one runner wins. The winner sets SENDING with a lease (`leaseSeconds`, default + 120). +- **Our own posts are recognisable.** Every post carries Slack message metadata: `event_type: qqq_message`, + `event_payload: {id, key}`. +- **Expired leases.** Before retrying an expired SENDING row, the runner reads `conversations.history` + (`include_all_metadata`, from the claim time) in that channel, using the same row's token. + - If our id is there, the runner records that `ts` as SENT. + - If history can't be read, it waits and retries the read. It doesn't post blind. + - So a job retry or a dead pod never double-posts. + - For files, the upload's `external_id`-style title marker plays the same role. +- **Dedupe at queue time.** A repeated idempotency key returns DUPLICATE. A `sendOnce` replay returns + QUEUED_OR_DUPLICATE. + +### 5.3 Retries, rate limits and health + +| Slack response | Result | +|---|---| +| HTTP 429 | Wait `Retry-After` (capped at 15 minutes); it isn't an attempt. The workspace limiter pauses that method | +| HTTP 5xx, timeouts, network errors; `ratelimited`, `service_unavailable`, `internal_error`, `fatal_error`, `request_timeout` | Transient: RETRY_WAIT after `retryMinutes` (1, 5, 15, 60, 240, 720). DEAD_LETTER after `maxAttempts` (6) | +| `channel_not_found`, `is_archived`, `msg_too_long`, `invalid_blocks`, `no_text`, `user_not_found`, `cannot_dm_bot`, `user_disabled` | Permanent: DEAD_LETTER at once | +| `not_in_channel`, the owner no longer a member, `invalid_auth`, `token_revoked`, `account_inactive`, `missing_scope` | FAILED_AUTH: mark the connection or workspace, and notify the owner (4.1). Never retried with another token | + +There's a client-side limiter per workspace row: at most one post per second per channel, and +`ratePerMinute` overall (default 50). Waiting under a limit isn't an attempt. + +**Health:** + +- After 10 consecutive transient failures a workspace is UNHEALTHY. While unhealthy, one message is tried + every 5 minutes. +- Optional alerts go through the core `QAlerts` producer: `slack.workspace.unhealthy`, + `slack.message.dead_letter` and `slack.delivery.unauthorized`. There's no dependency on qbit-alerts. + +### 5.4 Record actions and retention + +**Record actions** are standard streamed-ETL processes with Review and Result lines: + +- **Resend** (DEAD_LETTER or FAILED_AUTH; re-authorized as the owner); +- **Cancel**; +- **Send Now**. + +**Retention:** a daily job clears bodies and variables after `bodyRetentionDays` (30) once a message is +final. It deletes final rows after `messageRetentionDays` (0 means keep). + +## 6. Channels, groups and templates + +### 6.1 Live channels (pickers) + +There's no channel table for user deliveries. Pickers and validation ask Slack, as the user: + +- **The user token:** `users.conversations` (public and private channels the user is in); +- **Otherwise:** the bot's channel list filtered by `conversations.members` containing the user. + +Results are cached for 5 minutes per connection, in memory only, with no disk. The picker shows +`#name (private)` with its workspace. + +### 6.2 `slack_channel` (Registered Channels) and `slack_user_group` (User Groups) + +These are tenant-scoped named targets for **system** and ownerless notices, for example monitors or +"#ops-alerts" for a tenant. + +**`slack_channel`:** + +- Fields: `name` (logical key, unique per workspace), `label`, `workspaceId`, `slackChannelId`, + `slackChannelName`, `isPrivate`, `botIsMember`, `isArchived`, `enabled`, `description`, `checkedAt`. +- The row needs the workspace's bot. +- A tenant admin who is a member of the channel registers it. The picker offers only their channels. +- Record actions: **Check channel** and **Send test message**. +- An ownerless send must come from a system session. It is still tenant-checked: the signal's tenant key must + equal the workspace's. + +**`slack_user_group`:** + +- Fields: `name`, `label`, `workspaceId`, `slackUsergroupId`, `handle`, `channelId`, `enabled`. +- A group target posts once, starting with ``. +- There's no DM fan-out. + +### 6.3 `slack_template` (Templates) + +The variable model is shared with qbit-email: + +- `{{name}}` and dotted paths; +- `{{#if}}` and `{{#each}}`; +- `{{name|fallback}}`; +- a declared variable schema (name, label, type, required, classification, and an optional bound table and + field), with the same rules as qbit-email section 5.2. + +The engine is the qbit's own, with no dependency on qbit-email internals (G1). + +| Field | Notes | +|---|---| +| `name`, `label`, `description`, `category`, `enabled`, `tenantKey` (optional; null means shared) | | +| `text` | mrkdwn. Values are escaped (`&`, `<`, `>`), so variables can't inject mentions or links | +| `blocksTemplate` | Optional Block Kit JSON, with variables in string positions only. Validated on save: structure, at most 50 blocks, under 40,000 characters | +| `variablesSchema` | JSON | + +The built-in templates are: + +- `notification`: severity emoji, title, body, a "View" button and a context line; +- `digest`; +- `delivery`: title, summary, link or file, source and run time. + +A **Preview** widget renders sample variables to Block Kit and sends nothing. + +## 7. Users without a connection + +A user with no ACTIVE connection gets no Slack messages. Their preferences show **Connect Slack**. There's +no email-based linking, because in a multi-tenant deployment an email match in somebody else's workspace +proves nothing (R2). For a single-tenant deployment, `allowEmailLinking(true)` lets the tenant bot link a +user with `users.lookupByEmail`, but only when the IdP marked the session email verified. It's off by default. + +## 8. Readiness and health + +| Check | Scope | How | +|---|---|---| +| `app` | Deployment | The client id and secret, signing secret and token key are set (presence only). The token key decodes to 32 bytes | +| `workspace:` | Tenant | `auth.test` on the bot: ok, `team_id` matches, scopes include the required set (optional ones are WARN) | +| `connection:` | User | `auth.test` on the user token, run lazily (on send failure, and daily) | +| `registeredChannels` | Tenant | Each enabled registered channel exists, isn't archived, and the bot is a member | +| `outbox` | Deployment | No DEAD_LETTER in the last hour, and the oldest QUEUED is younger than 10 minutes | + +- **Where results show:** stored on the rows and shown on the home page. A tenant admin sees their tenant's + results, and a user sees their own in Preferences. +- **Checks run:** every `readinessIntervalMinutes` (15), on the **Check readiness** action, and from + `SlackApi.readiness*`. Messages never include token values or exception text. +- **Health:** when `qqq-middleware-health` is present, the qbit registers `HealthCheckMetaData` `slack`. + - It reports the stored deployment-level readiness (`app` and `outbox`), without calling Slack on each + probe. + - Tenant workspace problems are details, not a pod-down signal. + - The payload follows the health program's conventions; qbit-email's check is the pattern. + +## 9. Output delivery (core SPI, Slack destination) + +The core part is specified in QRun-IO/qqq `docs/specs/OUTPUT-DELIVERY.md` (its own PR). Summary: + +### 9.1 Core (thin, generic) + +- **`DeliveryDestinationInterface`**, in core package `delivery`: + - `getType()`; + - `describeTargets(context, chosenValues)`: the next contingent picker field with its options, as the + current user; + - `validate(request)`; + - `deliver(request)`, returning `DeliveryResult`. +- **Metadata.** `QDeliveryDestinationMetaData` (name, label, icon, provider code reference, + `maxClassification`, `maxAttachmentBytes`, `acceptsLinks`) is registered in the supplemental + `DeliveryDestinationsMetaData`. +- **`DeliveryRequest`** carries: + - `destinationName`, `targetValues`, `ownerUserId`; + - `sourceType` (PROCESS / JOB / SCHEDULED_REPORT / SCHEDULED_JOB / EXPORT / SAVED_VIEW / ASSISTANT_TASK) + and `sourceKey`; + - `title`, `summary` (Markdown), `link`; + - `attachments` (name, MIME type, size, storage table and reference; streamed, no filesystem); + - `contentClassification` (the highest field classification in the payload) and `restrictedFields` + (their labels); + - `idempotencyKey`. +- **`DeliverOutputAction`** runs as the owner. It: + 1. checks permissions; + 2. refuses content above the destination's `maxClassification`, naming the fields; + 3. turns an over-size attachment into a link (an authenticated app link, or a presigned storage URL only + for content below PII); + 4. writes `delivery_log`; + 5. calls the destination. +- **`delivery_log`** (core table, optional provider): one row per delivery and attempt, with owner, source, + destination, a target summary (never secrets), status, attempts, external reference, error, and a unique + idempotency key. Durable jobs deliver through the `jobs_effect` outbox, so it's exactly once. +- **`output_delivery`** (core table): saved delivery choices attached to a scheduled report, scheduled job + or saved view. It's owner-locked. +- **Built-in destinations:** DOWNLOAD (today's behaviour), and EMAIL through core `SendMessageAction`. + qbit-email can register a richer one. +- **UI API:** `GET /qqq/v1/delivery/destinations` and + `POST /qqq/v1/delivery/destinations/{name}/targets` (contingent options). + +### 9.2 Slack destination (this qbit) + +`SlackDeliveryDestination`, name `slack`, `maxClassification` CONFIDENTIAL. That means PII and PHI are refused, +or left out (R7). + +- **Picker:** + 1. **Workspace:** the current user's ACTIVE connections, tenant-filtered. + 2. **Where:** "Direct message to me", then the channels from 6.1. +- **Deliver:** queues a `slack_message` for the owner, with the `delivery` template. + - Attachments use the files upload flow: `files.getUploadURLExternal`, a streamed PUT from storage, then + `files.completeUploadExternal` into the channel or DM. + - Over `maxAttachmentBytes` (default 50 MB), the attachment becomes a link (9.1). + - The `delivery_log` id is stored on the message, and the message status maps back to the log row + (QUEUED, then DELIVERED, FAILED or FAILED_AUTH). +- **Scheduled deliveries** (`output_delivery` rows) store the workspace row id and channel id, never a + token. Each run re-authorizes as the owner (4.1, R4). + +### 9.3 Exports and PHI (R7) + +For an export or report delivered to Slack, the producer computes `contentClassification` from the +exported columns. + +- **Above CONFIDENTIAL:** the user sees "This export includes restricted fields (Patient name, Date of birth). + Slack can't receive them. Leave them out, or choose Download or Email." +- **"Leave out restricted columns"** is the option, saved on the delivery. Core re-renders the export without + them. +- **Links:** a presigned URL is never used for PII or PHI. An authenticated app link is. + +## 10. Unified "notify a user" routing + +**Decision:** the one call stays core `QAlerts.notify(NotificationSignal)` with an audience and a +`NotificationCategory`. It's in core already (RC.6) and is the thin contract. **The fan-out lives in +qbit-alerts**, which owns the per-category preferences, recipients, quiet hours and digests. Building it in +core would create a second preference store. + +| Piece | Where | Change | +|---|---|---| +| Producer contract | core `QAlerts.notify` | None | +| Transport | core `SendMessageAction` provider `slack`, address `user:`, owner = recipient | No qbit-slack import (G1) | +| Preferences | `alerts_notification_preference` | Add `slackMode` (OFF / IMMEDIATE / DAILY_DIGEST, default OFF) and `slackMinSeverity` (default WARNING). SECURITY gets the title and link only | +| Quiet hours | `alerts_user_preference` | Apply to email and Slack alike. CRITICAL bypasses them | +| Digest | Digest job | Also one Slack DM per user at the digest hour (`digest` template) | +| Availability | qbit-alerts | The Slack column shows when `SendMessageAction.isAvailable("slack")`. For a user without a connection it reads "Connect Slack first" | +| Recipient state | `alerts_notification_recipient` | Add `slackStatus` and `slackMessageId` | +| Idempotency | qbit-alerts | `notify:::slack` | + +The DM goes through the recipient's own connection and their tenant's bot (4.1), so a user's notifications +never travel through another tenant's install. + +qbit-slack's own Preferences section (core #1162) has: + +- the list of Connections, read-only; +- the actions **Connect Slack**, **Disconnect** and **Send me a test message**; +- for tenant admins, **Install for my organization**. + +Without qbit-alerts, callers use `SendMessageAction` (`slack`, `me` or `user:`) directly. + +## 11. PHI and PII + +Slack is not HIPAA-eligible on standard plans, so every Slack payload is non-PHI. + +1. **Template variables** classified PHI or PII (declared, or from a bound field through core + `ClassifiedValues`) are refused when the template is saved. At send time, such a value becomes its + `ClassifiedValueTokens` token if the variable allows `tokenize`. Otherwise the send is REFUSED: "Variable + x is classified PHI and can't be sent to Slack." +2. **Linked records** become a "View" link into the app, where normal permissions apply. Field values are + never copied. +3. **Free text:** `SlackPayloadGuard` refuses values the caller marked classified, and `phiPatterns` matches + (default SSN and MRN-style). It refuses rather than silently redacting. +4. **Exports and reports** follow 9.3. Attachments are checked through `contentClassification`, never by + reading their bytes. +5. **SECURITY** category: title and link only. +6. **Storage:** `slack_message` is classified PII (owner, channel). Tokens are encrypted and HIDDEN. +7. **Canary test:** a fixture table has a PHI field holding a unique canary string. The test sends it + through every path: template variable, linked record, alerts body, update, and export delivery. It + asserts that the canary appears in no table column, no fake-Slack HTTP body or upload, and no log line. + +## 12. Interactivity (phase 2) + +- **Route:** `POST /qqq/hooks/slack/interactions`, verified with the signing secret. + - `X-Slack-Signature` must equal `v0=` + HMAC-SHA256 over `v0:{ts}:{raw body}`. + - The timestamp must be within 5 minutes, and the comparison is constant-time. + - A failure gets 401 and is logged as rejected, without the body. +- **Buttons:** `SlackApiRequest.withAction(...)` names a registered `SlackActionMetaData`, which has: + - `name` and `label`; + - `style` and an optional `confirm` dialog; + - `processName` and `permissionName`. + + The button value is an opaque token that the server signs over the message id, action, table, record id + and expiry. +- **Execution:** + 1. Acknowledge within 3 seconds, then queue the work. + 2. Map (`team_id`, Slack `user.id`) to an ACTIVE `slack_connection` in a workspace row **of the message's + workspace row**, then to the QQQ user. An unconnected user gets an ephemeral "Connect Slack in + Preferences". + 3. Run the process under that user's automated session, through the normal process path, so permissions, + tenant locks and record locks apply. Denied gets an ephemeral "You don't have permission to Approve". + 4. On success, `chat.update` (an UPDATE row) adds "Approved by Ada at 14:02". +- **Audit:** `slack_interaction` (Interactions) records workspace, Slack user, QQQ user, message, action, + outcome (DONE / DENIED / FAILED / REJECTED / EXPIRED), error and dates. It's a child list on the message. +- **Built-in actions:** `acknowledge_alert` and `resolve_alert`, by process name. `approve` and `reject` are + for apps to register. +- **Revocation events:** `tokens_revoked` and `app_uninstalled` arrive through the Events API at + `/qqq/hooks/slack/events`. +- **Later:** slash commands and the App Home tab. + +## 13. App and UI + +The UI goes through the `/qqq/v1` UI API only. + +- **App:** `slack`, labelled "Slack". +- **Home (the only dashboard):** + - For a user: their connections and recent deliveries. + - For a tenant admin: workspace readiness with fix hints, counts (queued, retrying, sent in 24 h, + dead letters and auth failures in 24 h), and recent failures linked to their records. +- **Nav:** Messages, Connections, Workspaces, Registered Channels, User Groups, Templates. +- **Relations:** attempts and interactions on a message. Connections, channels and groups as child lists + on the workspace. +- **Record actions** have plain labels and never appear in the nav: + - Check readiness, Check channel and Send test message; + - Disconnect and Revoke; + - Resend, Cancel and Send Now. +- **Tables:** all are standard visible tables with record screens, the API (matching the admin), audit, + ESB events, and Quick Search on Messages and Connections. They're secured by permissions, owner locks + and tenant locks. +- **Layout:** readable at every width, light and dark. + +## 14. Data model and configuration + +### 14.1 Tables + +| Table | Label | Lock | Nav | +|---|---|---|---| +| `slack_workspace` | Workspaces | Tenant | Yes | +| `slack_connection` | Connections | Owner (`userId`) and tenant through the workspace | Yes | +| `slack_channel` | Registered Channels | Tenant through the workspace | Yes | +| `slack_user_group` | User Groups | Tenant through the workspace | Yes | +| `slack_template` | Templates | Tenant (null means shared) | Yes | +| `slack_message` | Messages | Owner and tenant | Yes | +| `slack_message_attempt` | Message Attempts | Through the message | Relation | +| `slack_interaction` | Interactions | Through the message | Relation (phase 2) | + +DDL is `docs/ddl/slack-postgres.sql`. kof22 applies it as a Liquibase changeset. + +### 14.2 `SlackQBitConfig` settings + +| Setting | Default | +|---|---| +| `backendName` | Required | +| `schedulerName` | Required | +| `bridgeProviderName` | `slack` | +| `destinationName` | `slack` | +| `secretResolver` | `System::getenv` | +| `allowedEnvNames` | The five names in 3.4 | +| `slackApiBaseUrl` | Overridden in tests | +| `publicBaseUrl` | | +| `oauthCallbackPath` | `/qqq/hooks/slack/oauth/callback` | +| `securityFields` | The tenant key field | +| `recordSecurityLocks` | | +| `tableMetaDataCustomizer` | | +| `dispatchIntervalSeconds` | 10 | +| `batchSize` | 50 | +| `maxAttempts` | 6 | +| `retryMinutes` | 1, 5, 15, 60, 240, 720 | +| `leaseSeconds` | 120 | +| `bodyRetentionDays` | 30 | +| `messageRetentionDays` | 0 | +| `readinessIntervalMinutes` | 15 | +| `membershipCacheMinutes` | 10 | +| `maxAttachmentBytes` | 50 MB | +| `allowEmailLinking` | false | +| `phiPatterns` | | +| `appName` | `slack` | +| `appLabel` | `Slack` | + +## 15. Phases and acceptance + +**Phase 1a: spec and scaffold (this PR).** + +- The repo standard is met. +- `./mvnw verify` passes. + +**Phase 1b: tables, cipher, outbox, dispatcher and provider.** + +- Tables, owner and tenant locks, `SlackTokenCipher` and `SlackRedaction`. +- Queue-time and send-time authorization (4.1). +- Claim, lease and reconcile; 429, transient and permanent handling; FAILED_AUTH with the owner alert. +- The core provider under `slack`. +- Fake-Slack-server tests: + - 429 with `Retry-After`; + - the crash and reconcile case, which posts once; + - duplicate keys; + - a thread waiting for its parent; + - an update keeping the same ts; + - a revoked connection; + - **two tenants and two workspaces** (4.1); + - token non-leakage. + +**Phase 1c: OAuth connect, `SlackApi`, readiness and health.** + +- Connect, Disconnect and Install through the signed state and callback route. +- The API surface (4.3). +- Readiness checks against the fake server (good, missing scope, revoked, bot not in channel). +- Health registration. + +**Phase 1d: templates, the PHI guard and the canary test.** + +**Phase 1e: delivery destinations.** + +- The core PR: SPI, `DeliverOutputAction`, `delivery_log`, `output_delivery`, the DOWNLOAD destination and + the UI API endpoints. +- The qbit's `SlackDeliveryDestination`, with contingent pickers and file upload. +- The qbit side builds on a `STAND-IN(core=#N, removeBy=RC.N)` copy until core releases. +- Tests: picker isolation, a PHI export refused, then delivered with the restricted columns left out, and + over-size becoming a link. + +**Phase 1f: app, home and preferences section.** + +- Built on core #1162 (RC.7). Until then the work is behind a stand-in. +- Screenshots: home, a message and a connection, light and dark, at 390 and 1440 px. + +**Phase 1g: qbit-alerts routing (section 10).** A separate qbit-alerts PR. + +**Phase 1h: kof22 adoption.** A draft Website-Backend PR, plus the Praetor Infisical PR (section 17). + +**Phase 2: interactivity and the Events API.** + +- Tests: + - a bad signature or a stale timestamp gives 401; + - a forged value is refused; + - an unconnected user is prompted; + - a user without permission is DENIED; + - a Slack user of another workspace row cannot act on this message; + - Approve runs and updates the message. + +## 16. Decisions (all recommendations accepted) + +1. The qbit is a core messaging provider and a core delivery destination. Callers use only core. +2. Connections are per user, through OAuth. Bot installs are per tenant. Tokens are encrypted, write-only + and HIDDEN, and the keys come from environment variables. +3. Every message has an owner. Authorization runs at queue time and again at send time. There is never a + token fallback. +4. Membership is checked with `conversations.members` at send time, cached for 10 minutes. +5. There's no email-based linking by default. It's an opt-in for single-tenant deployments, with an + IdP-verified email. +6. The outbox posts exactly once, using message-metadata reconciliation. +7. The template variable model is shared with qbit-email, with the qbit's own engine. +8. Routing goes through core `QAlerts.notify`, with the fan-out in qbit-alerts. This supersedes the + 2026-10-13 "never per user" decision. +9. Core output delivery is a thin SPI plus a log, owned by core, and specified in qqq. +10. No PHI or PII goes to Slack. Exports are blocked, or sent without the restricted columns. There's a + canary test. +11. User groups are mentions in a registered channel. +12. kof22 adopts through a draft PR. The secrets are seeded write-only in Infisical. + +## 17. Owner steps (outside code) + +1. **Workspace.** Use the kof22 Slack workspace, or create one (slack.com/get-started). You need to be a + workspace admin. +2. **App.** + 1. At api.slack.com/apps, choose **Create New App**, then **From a manifest**. + 2. Pick the workspace and paste `docs/slack-app-manifest.yaml`, after replacing `` with + `admin.kof22.com`. + 3. Create the app. Leave public distribution off for kof22, which is single-tenant. +3. **Credentials.** From **Basic Information**, copy the **Client ID**, **Client Secret** and **Signing + Secret**. Generate the token key with `openssl rand -base64 32`. Don't paste any of them into chat, an + issue or a commit. You don't need to copy a bot token: it's created when you click **Connect Slack**, + then **Install for my organization**, in kof22 Preferences. +4. **Infisical.** A Praetor PR adds the folder `/kof22-website-slack` in the `k8s-galaxy` project, following + the `kof22-webhooks.tf` pattern: write-only, ephemeral variables, `value_wo`. The keys are `client-id`, + `client-secret`, `signing-secret` and `token-key`. Seed only those resources: + + ```bash + TF_VAR_kof22_slack_client_id='…' TF_VAR_kof22_slack_client_secret='…' \ + TF_VAR_kof22_slack_signing_secret='…' TF_VAR_kof22_slack_token_key="$(openssl rand -base64 32)" \ + tofu apply -target=infisical_secret_folder.kof22_website_slack \ + -target=infisical_secret.kof22_slack_client_id \ + -target=infisical_secret.kof22_slack_client_secret \ + -target=infisical_secret.kof22_slack_signing_secret \ + -target=infisical_secret.kof22_slack_token_key + ``` + + The token key has no other copy. Losing it means every user reconnects. +5. **cluster-gitops.** An ExternalSecret maps the keys to `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, + `SLACK_SIGNING_SECRET` and `SLACK_TOKEN_KEY` in `kof22-website-prod`. The coordinator sequences it with + the Website-Backend PR. +6. **First use.** In kof22, open Preferences, then Slack, then **Install for my organization**, and approve + in Slack. Invite the app to any channel the bot should post in (`/invite @`). diff --git a/docs/slack-app-manifest.yaml b/docs/slack-app-manifest.yaml new file mode 100644 index 0000000..7227160 --- /dev/null +++ b/docs/slack-app-manifest.yaml @@ -0,0 +1,41 @@ +# Slack app manifest for qbit-slack (docs/DESIGN.md section 3.5). +# Replace with the admin host (for kof22: admin.kof22.com) before creating the app. +display_information: + name: QQQ + description: Messages, reports and exports from your QQQ apps. +features: + bot_user: + display_name: QQQ + always_online: false +oauth_config: + redirect_urls: + - https:///qqq/hooks/slack/oauth/callback + scopes: + bot: + - chat:write + - im:write + - channels:read + - groups:read + - users:read + - files:write + - usergroups:read + - chat:write.customize + user: + - chat:write + - channels:read + - groups:read + - im:write + - files:write + - users:read +settings: + interactivity: + is_enabled: true + request_url: https:///qqq/hooks/slack/interactions + event_subscriptions: + request_url: https:///qqq/hooks/slack/events + bot_events: + - app_uninstalled + - tokens_revoked + org_deploy_enabled: false + socket_mode_enabled: false + token_rotation_enabled: false -- 2.54.0