Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to Turbo EA 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/).

## [2.12.0] - 2026-07-13

### Added
- **UI extension SDK 1.13 — saved-report localStorage persistence for extension reports.** The `useSavedReport` SDK hook now re-exports the core hook's `consumeConfig` / `persistConfig` / `resetAll` layer, so an extension report page keeps its filters and selection across a browser refresh exactly like a core report (URL saved report > localStorage > defaults).

## [2.11.0] - 2026-07-13

### Added
Expand Down Expand Up @@ -88,7 +93,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- **Admin → Extensions: remove a license, and a smoother file install.** The Installed tab now has a **Remove license** action (soft-disables licensed extensions until a license is re-applied — no data is deleted). Installing an unlicensed extension from a file no longer dead-ends with an error pointing at the Installed tab: the license dialog opens inline and the install continues automatically once the license is applied.
- **Instance-bound extension licensing.** Every installation now mints a unique instance ID (`TEA-XXXX-XXXX-XXXX`, shown on Admin → Extensions with a copy button) that identifies it for licensing: purchases carry it automatically from the in-app Store (and the storefront checkout asks for it), so every extension bought for the instance — by any administrator, under any email — lands in one combined license. Licenses are bound to the ID: one issued for a different instance is refused with a clear message, and a restored/copied database explains itself with a banner instead of silently mis-licensing. The ID identifies only — it is never a credential — and it travels with workspace transfer so host migrations keep licenses working.
- **Admin → Extensions** gains a page title and icon, an intro covering both install models (one-click Store and offline file upload), and a note that extensions are built and signed by Turbo EA (not self-built or third-party) with a link to consulting for a tailored build. Documented in the admin guide across all languages.
- **Extensions can extend Architecture Decision Records.** Two new UI extension points (UI SDK 1.3) let a licensed extension render its own panel on the ADR editor and preview pages — e.g. a value-savings form — and contribute extra sections to the ADR DOCX export so that data appears in the exported document. ADRs now also carry an `ext.*` attributes bag that extensions write to; it is frozen automatically once the decision is signed and carried into revisions and duplicates, so figures approved by the signatories stay accurate.
- **Extensions can extend Architecture Decision Records.** Two new UI extension points (UI SDK 1.3) let a licensed extension render its own panel on the ADR editor and preview pages, and contribute extra sections to the ADR DOCX export so that data appears in the exported document. ADRs now also carry an `ext.*` attributes bag that extensions write to; it is frozen automatically once the decision is signed and carried into revisions and duplicates, so figures approved by the signatories stay accurate.

### Security
- Installed extensions are now verified byte-for-byte at every startup, not just by their manifest signature: each file is re-hashed against the signed manifest and backend code is re-derived from the verified wheels, so tampering with extension code on the data volume is detected and quarantined instead of loaded.
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.11.0
2.12.0
4 changes: 2 additions & 2 deletions backend/alembic/versions/122_add_adr_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

ADRs are not cards, so extensions cannot extend them through the metamodel
field-contribution capability. This JSONB column lets extensions stash their
own data under ``ext.<key>.*`` top-level keys (e.g. the value-savings
extension writes ``ext.savings``). It is frozen once the ADR is signed and
own data under ``ext.<key>.*`` top-level keys (an extension might write, e.g.,
``ext.savings``). It is frozen once the ADR is signed and
carried into revisions by the API layer.

Revision ID: 122
Expand Down
4 changes: 2 additions & 2 deletions backend/app/models/architecture_decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ class ArchitectureDecision(Base, UUIDMixin, TimestampMixin):

# Extension attributes bag. ADRs are not cards, so extensions cannot use the
# metamodel field-contribution capability here; instead they stash their own
# data under ``ext.<key>.*`` top-level keys (e.g. the value-savings extension
# writes ``ext.savings``). Frozen automatically once the ADR is signed
# data under ``ext.<key>.*`` top-level keys (an extension writes, e.g.,
# ``ext.savings``). Frozen automatically once the ADR is signed
# (update_adr blocks edits on signed decisions) and carried into revisions.
attributes: Mapped[dict] = mapped_column(JSONB, default=dict, server_default="{}")

Expand Down
2 changes: 1 addition & 1 deletion backend/tests/api/test_adr.py
Original file line number Diff line number Diff line change
Expand Up @@ -894,7 +894,7 @@ async def test_list_adrs_for_card(self, client, db, card_env):


class TestAdrAttributes:
"""The attributes bag that ADR extensions (e.g. value-savings) write to."""
"""The attributes bag that ADR extensions write to."""

async def test_default_is_empty_object(self, client, db, adr_env):
admin = adr_env["admin"]
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/ea-delivery/ADREditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@ export default function ADREditor() {
</Paper>
)}

{/* ── Extension ADR panels (SDK 1.3, e.g. value-savings) ── */}
{/* ── Extension ADR panels (SDK 1.3) ── */}
{!isNew &&
id &&
adrPanels
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/ea-delivery/adrExport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe("exportAdrsToDocx — extension sections", () => {

it("invokes registered export builders with the ADR and still saves the file", async () => {
const build = vi.fn(() => [
{ heading: "Value Savings", paragraphs: ["Total: 50,000"], table: { headers: ["A"], rows: [["1"]] } },
{ heading: "Extra Section", paragraphs: ["Total: 50,000"], table: { headers: ["A"], rows: [["1"]] } },
]);
registerExtension("daaf", {
key: "daaf",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/ea-delivery/adrExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ export async function exportAdrsToDocx(adrs: ArchitectureDecision[]): Promise<vo
children.push(new Paragraph({ spacing: { after: SPACING_AFTER_TABLE } }));
}

// Extension-contributed sections (SDK 1.3) — e.g. value savings. Each
// Extension-contributed sections (SDK 1.3). Each
// builder returns plain data that we render with the document's own styles;
// a throwing builder is skipped so a bad extension never breaks the export.
for (const { extKey, contribution } of getExtensionAdrExportSections()) {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/extensionHost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ describe("extensionHost", () => {
});

it("pins the current UI SDK version", () => {
expect(UI_SDK_VERSION).toBe("1.12");
expect(UI_SDK_VERSION).toBe("1.13");
});

it("aggregates generic slots (component + data), sorts by order, drops invalid ones", () => {
Expand Down
21 changes: 18 additions & 3 deletions frontend/src/lib/extensionHost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* `ext.{key}.*` field type used inside card attribute sections, plus — SDK 1.5
* — an optional admin-side config editor for that type's config object), survey
* templates (SDK 1.2), — SDK 1.3 — ADR panels (a component rendered on
* the Architecture Decision Record editor/preview, e.g. a value-savings form
* the Architecture Decision Record editor/preview, e.g. a custom form
* writing the ADR `ext.*` attributes bag) and ADR export sections (plain data
* a plugin contributes into the core ADR DOCX export), and — SDK 1.4 — a
* headless field-visibility provider (hides specific card fields at render
Expand Down Expand Up @@ -78,7 +78,7 @@ import { useSavedReport as useCoreSavedReport } from "@/hooks/useSavedReport";
import * as tokens from "@/theme/tokens";
import type { ArchitectureDecision, Card } from "@/types";

export const UI_SDK_VERSION = "1.12";
export const UI_SDK_VERSION = "1.13";

/**
* Core nav groups an extension route may request placement into (instead of the
Expand Down Expand Up @@ -191,7 +191,7 @@ export interface ExtensionAdrPanelProps {
/**
* A panel rendered on the ADR editor and preview pages (SDK 1.3). ADRs are not
* cards, so this is the sanctioned place to attach ADR-scoped UI — e.g. a
* value-savings form that reads/writes the ADR `ext.*` attributes bag via
* custom form that reads/writes the ADR `ext.*` attributes bag via
* `PATCH /adr/{id}`. `appliesTo` has no analogue (there is only one ADR
* "type"); gate with `permission` if needed. Rendered read-only-friendly: the
* component should respect `signed` to disable editing once frozen.
Expand Down Expand Up @@ -556,17 +556,32 @@ export function ExtensionReportShell(props: ReportShellProps) {
* core save/share dialog re-exported verbatim: name/description/visibility/
* share-with-users → POST /saved-reports). Ungated — permissions are the same
* `saved_reports.*` keys every core report uses, enforced server-side.
*
* SDK 1.13 additionally re-exports the core hook's **localStorage
* auto-persist** layer — `consumeConfig` / `persistConfig` / `resetAll` — so an
* extension report keeps its filters + selection across a page refresh exactly
* like a core report (same three-tier priority: URL saved report > localStorage
* > component defaults). `consumeConfig()` is called once on mount to restore,
* `persistConfig(config)` on every change to auto-save, and `resetAll()` clears
* both localStorage and the URL saved report. See any core report
* (`CostReport.tsx`) for the canonical usage.
*/
export function useExtensionSavedReport(reportType: string): {
config: Record<string, unknown> | null;
savedReportId: string | null;
name: string | null;
consumeConfig: () => Record<string, unknown> | null;
persistConfig: (config: Record<string, unknown>) => void;
resetAll: () => void;
} {
const saved = useCoreSavedReport(reportType);
return {
config: (saved.savedReport?.config as Record<string, unknown> | undefined) ?? null,
savedReportId: saved.savedReport?.id ?? null,
name: saved.savedReportName,
consumeConfig: saved.consumeConfig,
persistConfig: saved.persistConfig,
resetAll: saved.resetAll,
};
}

Expand Down
19 changes: 19 additions & 0 deletions frontend/src/lib/extensionSavedReport.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,25 @@ describe("SDK saved-report participation (SDK 1.6)", () => {
expect(window.TurboEA?.sdk.CardDetailSidePanel).toBeTypeOf("function");
});

it("useSavedReport exposes the SDK 1.13 localStorage-persistence layer", () => {
// consumeConfig / persistConfig / resetAll let an extension report keep its
// filters + selection across a refresh exactly like a core report.
function Probe() {
const saved = useExtensionSavedReport("ext:daaf:quadrant");
const ok =
typeof saved.consumeConfig === "function" &&
typeof saved.persistConfig === "function" &&
typeof saved.resetAll === "function";
return <div>{ok ? "has-persistence" : "missing"}</div>;
}
render(
<MemoryRouter initialEntries={["/ext/daaf/quadrant"]}>
<Probe />
</MemoryRouter>,
);
expect(screen.getByText("has-persistence")).toBeInTheDocument();
});

it("useSavedReport loads the config for ?saved_report_id=", async () => {
vi.mocked(api.get).mockResolvedValue({
id: "r1",
Expand Down
Loading