Feat v0.9.4 package adoption roles (#78)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m55s
CI/CD / test-sqlite (push) Successful in 3m7s
CI/CD / build-and-deploy (push) Successful in 1m19s
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m55s
CI/CD / test-sqlite (push) Successful in 3m7s
CI/CD / build-and-deploy (push) Successful in 1m19s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #78.
This commit is contained in:
52
CHANGELOG.md
52
CHANGELOG.md
@@ -2,6 +2,58 @@
|
|||||||
|
|
||||||
All notable changes to Armature are documented here.
|
All notable changes to Armature are documented here.
|
||||||
|
|
||||||
|
## v0.9.4 — Package Adoption + Roles
|
||||||
|
|
||||||
|
Packages can now declare `adoptable: true` in their manifest. When a team
|
||||||
|
adopts an adoptable package, a team-scoped copy is created that references
|
||||||
|
the original (shared assets, no disk duplication). The package's
|
||||||
|
`requires_roles` auto-populate into a new `team_role_catalog` table so
|
||||||
|
team admins know which roles to assign.
|
||||||
|
|
||||||
|
**Schema**
|
||||||
|
|
||||||
|
- Migration 017: `adoptable` and `adopted_from` columns on `packages`.
|
||||||
|
`team_role_catalog` table (team_id, role, source_package_id) with
|
||||||
|
unique constraint. Both Postgres and SQLite dialects.
|
||||||
|
|
||||||
|
**Store + Models**
|
||||||
|
|
||||||
|
- `PackageRegistration`: `Adoptable bool`, `AdoptedFrom *string`.
|
||||||
|
- `PackageStore`: `ListAdoptable()`, `GetByAdoptedFrom()`.
|
||||||
|
- `TeamRoleCatalogEntry` model struct.
|
||||||
|
- `TeamStore`: `AddRoleToCatalog`, `ListRoleCatalog`,
|
||||||
|
`RemoveRoleCatalogBySource`.
|
||||||
|
|
||||||
|
**Handlers**
|
||||||
|
|
||||||
|
- `POST /teams/:teamId/packages/:id/adopt` — adopt a global adoptable
|
||||||
|
package into the team. Creates team-scoped registration, clones
|
||||||
|
workflow if applicable, populates role catalog. Idempotent.
|
||||||
|
- `GET /teams/:teamId/packages/adoptable` — list available packages
|
||||||
|
with adoption status per team.
|
||||||
|
- `DELETE /teams/:teamId/packages/:id/unadopt` — remove adopted package
|
||||||
|
and clean up role catalog entries.
|
||||||
|
- `GET /teams/:teamId/roles/catalog` — list known roles for a team.
|
||||||
|
|
||||||
|
**Manifest Validation**
|
||||||
|
|
||||||
|
- `adoptable: true` parsed as `ManifestInfo.Adoptable`.
|
||||||
|
- Rejected on `library` and `test-runner` types.
|
||||||
|
- Persisted to DB on package install.
|
||||||
|
|
||||||
|
**Deprecation**
|
||||||
|
|
||||||
|
- `POST /teams/:teamId/workflows/:id/adopt` (`AdoptTeamWorkflow`) now
|
||||||
|
returns `X-Deprecated` header and logs a deprecation warning.
|
||||||
|
Use the package-level adoption endpoint instead.
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
|
||||||
|
- 11 new tests: 5 manifest validation, 3 role catalog store, 3 handler
|
||||||
|
integration (adopt success, idempotent, non-adoptable rejection).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v0.9.3 — Team User Roles
|
## v0.9.3 — Team User Roles
|
||||||
|
|
||||||
Promotes the team role system from a single-role-per-member model to a
|
Promotes the team role system from a single-role-per-member model to a
|
||||||
|
|||||||
99
ROADMAP.md
99
ROADMAP.md
@@ -105,11 +105,14 @@ Manifest `requires_roles` field (advisory). Starlark `teams` module
|
|||||||
with `get_member_roles()` and `has_role()`. Team-admin UI with role
|
with `get_member_roles()` and `has_role()`. Team-admin UI with role
|
||||||
badge chips and assignment dropdown. 10 new tests.
|
badge chips and assignment dropdown. 10 new tests.
|
||||||
|
|
||||||
**v0.9.4 — Package Adoption + Roles**
|
**v0.9.4 — Package Adoption + Roles** *(completed)*
|
||||||
|
|
||||||
`scope: adoptable` manifest field. When a team adopts an adoptable
|
`adoptable` manifest field + `team_role_catalog` table. When a team
|
||||||
package, the package's `requires_roles` auto-populate into the team's
|
adopts an adoptable package, the package's `requires_roles` auto-populate
|
||||||
role slots. Replaces `AdoptTeamWorkflow` clone mechanism.
|
into the team's role catalog. Adopted packages reference the original via
|
||||||
|
`adopted_from` column (shared assets, no disk duplication).
|
||||||
|
`AdoptTeamWorkflow` deprecated in favor of package-level adoption.
|
||||||
|
4 new endpoints, migration 017, 11 new tests.
|
||||||
|
|
||||||
**v0.9.5 — Typed Forms → SDK Primitive**
|
**v0.9.5 — Typed Forms → SDK Primitive**
|
||||||
|
|
||||||
@@ -182,31 +185,59 @@ a clean visual foundation. Design doc: `docs/DESIGN-notes-v011x.md`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### v0.12.x — Reference Extensions (Remaining)
|
### v0.12.x — Chat Reference Extension
|
||||||
|
|
||||||
The kernel is complete. This series proves the platform by shipping
|
Chat becomes the second reference extension — human-to-human messaging
|
||||||
first-party extensions that exercise every primitive. These are
|
built entirely on Armature's extension architecture. Where notes proved
|
||||||
**extensions, not kernel code** — they ship as `.pkg` files and can be
|
surfaces, panels, and storage, chat proves **realtime**, **cross-package
|
||||||
uninstalled.
|
composition**, and **extensible data models**. Chat is human-to-human
|
||||||
|
first; AI participants arrive via `llm-bridge` (v0.13.x) extending chat
|
||||||
|
through folder attributes and slot contributions.
|
||||||
|
Design doc: `docs/DESIGN-chat-v012x.md`.
|
||||||
|
|
||||||
| Version | Title |
|
| Version | Title |
|
||||||
|---------|-------|
|
|---------|-------|
|
||||||
| v0.12.0 | `vector-store` Library |
|
| v0.12.0 | UI/UX Foundation |
|
||||||
| v0.12.1 | `llm-bridge` Library |
|
| v0.12.1 | Conversation Folders + Attributes |
|
||||||
| v0.12.2 | `file-share` Extension |
|
| v0.12.2 | Reactions + Threads + Pins |
|
||||||
| v0.12.3 | `code-workspace` Extension |
|
| v0.12.3 | Rich Compose + Attachments |
|
||||||
| v0.12.4 | `image-gen` + `image-edit` Extensions |
|
| v0.12.4 | @Mentions + Notifications |
|
||||||
| v0.12.5 | Chat System |
|
| v0.12.5 | Link Previews + Message Formatting |
|
||||||
|
| v0.12.6 | Conversation Themes + Personality |
|
||||||
|
| v0.12.7 | Composability: Slots + Actions |
|
||||||
|
| v0.12.8 | Panels + Quality Gate |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### v0.13.x — Sidecar Tier + Polish
|
### v0.13.x — Reference Libraries + Extensions
|
||||||
|
|
||||||
|
Core libraries and remaining extensions. `llm-bridge` is the key
|
||||||
|
delivery — extends both notes and chat through composability primitives,
|
||||||
|
and introduces the **tool meta-tool** pattern: `sw.actions.list()` becomes
|
||||||
|
the LLM tool registry, so installing an extension = granting AI a new
|
||||||
|
capability. Zero configuration.
|
||||||
|
|
||||||
| Version | Title |
|
| Version | Title |
|
||||||
|---------|-------|
|
|---------|-------|
|
||||||
| v0.13.0 | Sidecar Tier |
|
| v0.13.0 | `vector-store` Library |
|
||||||
| v0.13.1 | Native Dialog Audit |
|
| v0.13.1 | `llm-bridge` Core Library |
|
||||||
| v0.13.2 | Stability + Migration Tooling |
|
| v0.13.2 | `llm-bridge` → Chat: Multi-Persona Context + Tool Meta-Tool |
|
||||||
|
| v0.13.3 | `llm-bridge` → Notes: AI Toolbar Actions |
|
||||||
|
| v0.13.4 | `file-share` Extension |
|
||||||
|
| v0.13.5 | `code-workspace` Extension |
|
||||||
|
| v0.13.6 | `image-gen` + `image-edit` Extensions |
|
||||||
|
| v0.13.7 | Tool Meta-Tool Hardening + Scoping |
|
||||||
|
| v0.13.8 | Integration Quality Gate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### v0.14.x — Sidecar Tier + Polish
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.14.0 | Sidecar Tier |
|
||||||
|
| v0.14.1 | Native Dialog Audit |
|
||||||
|
| v0.14.2 | Stability + Migration Tooling |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -219,15 +250,20 @@ Gate criteria:
|
|||||||
- All kernel Starlark modules documented with examples
|
- All kernel Starlark modules documented with examples
|
||||||
- All `api_routes` covered by OpenAPI spec
|
- All `api_routes` covered by OpenAPI spec
|
||||||
- Upgrade path tested from v0.8.0 → v1.0.0
|
- Upgrade path tested from v0.8.0 → v1.0.0
|
||||||
- At least 3 reference extensions shipped (vector-store, llm-bridge, chat)
|
- Notes reference extension shipped with all v0.11.x features
|
||||||
- At least 1 cross-extension composability demo (image-gen → chat:image-actions)
|
- Notes UI/UX reviewed against v0.11.0 design principles — no placeholder UI
|
||||||
|
- Chat reference extension shipped with all v0.12.x features
|
||||||
|
- `llm-bridge` extends both notes and chat through composability
|
||||||
|
- Tool meta-tool demonstrated: AI uses 3+ extension actions in a
|
||||||
|
single conversation turn
|
||||||
|
- Multi-persona context archetypes demonstrated
|
||||||
|
- Admin safety rails validated
|
||||||
|
- At least 2 panels consumed cross-package
|
||||||
|
- At least 2 slot contributions per host surface demonstrated
|
||||||
|
- Note and conversation sharing functional end-to-end
|
||||||
- Headless E2E green on PG + SQLite
|
- Headless E2E green on PG + SQLite
|
||||||
- `armature-ca.sh` + mTLS deployment guide
|
- `armature-ca.sh` + mTLS deployment guide
|
||||||
- Single-binary + Docker + K8s deployment paths documented
|
- Single-binary + Docker + K8s deployment paths documented
|
||||||
- Notes reference extension shipped with all v0.11.x features
|
|
||||||
- Notes UI/UX reviewed against v0.11.0 design principles — no placeholder UI
|
|
||||||
- At least 1 panel shipped and consumed cross-package (notes.reference → chat)
|
|
||||||
- Panel lifecycle test coverage in SDK test runner
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -278,9 +314,16 @@ These are candidates, not commitments. Each requires a design doc.
|
|||||||
| `workspace` for real filesystem | Flat blob store can't serve git/compilers/ffmpeg. Managed disk paths. |
|
| `workspace` for real filesystem | Flat blob store can't serve git/compilers/ffmpeg. Managed disk paths. |
|
||||||
| Capability negotiation at install | Fail loud with actionable message, not silently at runtime. |
|
| Capability negotiation at install | Fail loud with actionable message, not silently at runtime. |
|
||||||
| Vector column with three-tier fallback | Works everywhere, works fast with pgvector. |
|
| Vector column with three-tier fallback | Works everywhere, works fast with pgvector. |
|
||||||
| Sidecar deferred to v0.13.x | HTTP module covers external APIs. Sidecars connect inward (no k8s RBAC needed). |
|
| Sidecar deferred to v0.14.x | HTTP module covers external APIs. Sidecars connect inward (no k8s RBAC needed). |
|
||||||
| Workflow redesign before reference extensions | Clean up debt and promote primitives before building on top. |
|
| Workflow redesign before reference extensions | Clean up debt and promote primitives before building on top. |
|
||||||
| Panels as kernel primitive (v0.10.x) | Z-index coordination, drag/resize, layout negotiation are kernel concerns. |
|
| Panels as kernel primitive (v0.10.x) | Z-index coordination, drag/resize, layout negotiation are kernel concerns. |
|
||||||
| UI/UX redesign as v0.11.0 | Every feature version builds on the visual foundation. Front-loading avoids double work. |
|
| UI/UX redesign as first version in each reference series | Every feature builds on the visual foundation. Front-loading avoids double work. |
|
||||||
| Notes as dedicated v0.11.x series (11 versions) | Complex enough to need changeset discipline. Each version independently shippable. |
|
| Notes as dedicated v0.11.x series (11 versions) | Complex enough to need changeset discipline. Each version independently shippable. |
|
||||||
| Notes before remaining reference extensions | Notes proves the full primitive surface. Other extensions build on proven patterns. |
|
| Notes before chat | Notes is simpler (no realtime) and proves storage/rendering/composability. Chat adds realtime + llm-bridge story. |
|
||||||
|
| Chat human-to-human first (v0.12.x) | AI is an extension concern. Keeps chat testable and usable standalone. |
|
||||||
|
| Chat as dedicated v0.12.x series (9 versions) | Second reference extension. Proves realtime, extensible data models, cross-package composition. |
|
||||||
|
| Folder attributes as extension bridge | Chat stores attributes it doesn't understand. llm-bridge contributes definitions. Zero coupling. |
|
||||||
|
| Action registry as tool registry (meta-tool) | Dynamic, zero-config AI tool-use. Installed extensions = AI capabilities. Uniquely Armature. |
|
||||||
|
| Layered prompt architecture (6 layers) | Admin safety not overridable. Clear separation: platform → space → character → context → tools → user. |
|
||||||
|
| Personas in llm-bridge, not chat | Chat sees AI as just another participant_type. Persona identity is llm-bridge's concern. |
|
||||||
|
| llm-bridge after notes + chat (v0.13.x) | Proves the composability hooks work without being designed for a specific consumer. |
|
||||||
|
|||||||
1074
docs/DESIGN-chat-v012x.md
Normal file
1074
docs/DESIGN-chat-v012x.md
Normal file
File diff suppressed because it is too large
Load Diff
171
docs/ROADMAP-v010x-shift.md
Normal file
171
docs/ROADMAP-v010x-shift.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# ROADMAP — v0.10.x+ Version Shift (Final)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Four new series inserted after v0.9.x. Panels (kernel primitive), Notes
|
||||||
|
(first reference extension), Chat (second reference extension), and the
|
||||||
|
remaining reference libraries/extensions including `llm-bridge` with
|
||||||
|
the tool meta-tool pattern.
|
||||||
|
|
||||||
|
## Full Roadmap
|
||||||
|
|
||||||
|
| Series | Title | Versions | Design Doc |
|
||||||
|
|--------|-------|----------|------------|
|
||||||
|
| v0.9.x | Multi-Surface + Workflow Redesign | 10 | — |
|
||||||
|
| **v0.10.x** | **Panels + Composable Layout** | 5 | `DESIGN-panels.md` |
|
||||||
|
| **v0.11.x** | **Notes Reference Extension** | 11 | `DESIGN-notes-v011x.md` |
|
||||||
|
| **v0.12.x** | **Chat Reference Extension** | 9 | `DESIGN-chat-v012x.md` |
|
||||||
|
| **v0.13.x** | **Reference Libraries + Extensions** | 9 | — |
|
||||||
|
| v0.14.x | Sidecar Tier + Polish | 3 | — |
|
||||||
|
| v1.0.0 | Stable Release | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.10.x — Panels + Composable Layout
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.10.0 | Panel Manifest + Lifecycle |
|
||||||
|
| v0.10.1 | FloatingPanel Primitive |
|
||||||
|
| v0.10.2 | Docked Panels + Mode Transitions |
|
||||||
|
| v0.10.3 | Panel Communication Patterns |
|
||||||
|
| v0.10.4 | Reference Panel: Notes (basic) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.11.x — Notes Reference Extension
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.11.0 | UI/UX Foundation |
|
||||||
|
| v0.11.1 | Deep Folders + Navigation |
|
||||||
|
| v0.11.2 | Wikilinks + Backlinks |
|
||||||
|
| v0.11.3 | Live Preview + Rich Editing |
|
||||||
|
| v0.11.4 | Note Sharing + Permissions |
|
||||||
|
| v0.11.5 | Graph + Outline Hardening |
|
||||||
|
| v0.11.6 | Quick Switcher + Commands |
|
||||||
|
| v0.11.7 | Daily Notes + Templates |
|
||||||
|
| v0.11.8 | Transclusion + Embeds |
|
||||||
|
| v0.11.9 | Composability: Slots + Actions |
|
||||||
|
| v0.11.10 | Panel Enhancement + Quality Gate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.12.x — Chat Reference Extension
|
||||||
|
|
||||||
|
Human-to-human first. AI via `llm-bridge` (v0.13.x).
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.12.0 | UI/UX Foundation |
|
||||||
|
| v0.12.1 | Conversation Folders + Attributes |
|
||||||
|
| v0.12.2 | Reactions + Threads + Pins |
|
||||||
|
| v0.12.3 | Rich Compose + Attachments |
|
||||||
|
| v0.12.4 | @Mentions + Notifications |
|
||||||
|
| v0.12.5 | Link Previews + Message Formatting |
|
||||||
|
| v0.12.6 | Conversation Themes + Personality |
|
||||||
|
| v0.12.7 | Composability: Slots + Actions |
|
||||||
|
| v0.12.8 | Panels + Quality Gate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.13.x — Reference Libraries + Extensions
|
||||||
|
|
||||||
|
Core libraries and remaining extensions. `llm-bridge` is the key
|
||||||
|
delivery — extends both notes and chat through composability primitives,
|
||||||
|
and introduces the tool meta-tool pattern.
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.13.0 | `vector-store` Library |
|
||||||
|
| v0.13.1 | `llm-bridge` Core Library |
|
||||||
|
| v0.13.2 | `llm-bridge` → Chat: Multi-Persona Context + Tool Meta-Tool |
|
||||||
|
| v0.13.3 | `llm-bridge` → Notes: AI Toolbar Actions |
|
||||||
|
| v0.13.4 | `file-share` Extension |
|
||||||
|
| v0.13.5 | `code-workspace` Extension |
|
||||||
|
| v0.13.6 | `image-gen` + `image-edit` Extensions |
|
||||||
|
| v0.13.7 | Tool Meta-Tool Hardening + Scoping |
|
||||||
|
| v0.13.8 | Integration Quality Gate |
|
||||||
|
|
||||||
|
**v0.13.1 — `llm-bridge` Core:**
|
||||||
|
Model abstraction, provider BYOK via connections, `complete()`,
|
||||||
|
`embed()`, `classify()`. Persona CRUD (name, avatar, system_prompt,
|
||||||
|
visibility). Layered prompt architecture (6 layers: admin safety →
|
||||||
|
folder context → persona identity → conversation → tools → user).
|
||||||
|
Admin safety rails as non-overridable platform setting.
|
||||||
|
|
||||||
|
**v0.13.2 — Chat Integration:**
|
||||||
|
Contributes folder attributes (system_prompt, context_policy,
|
||||||
|
model_override, allowed_tools) to chat. Contributes "Invite AI" /
|
||||||
|
"Dismiss AI" to `chat:participant-actions`. Contributes "AI Reply" /
|
||||||
|
"AI Summarize" to `chat:message-actions`. Contributes "Ask AI" to
|
||||||
|
`chat:composer-tools`. Context archetype implementation (resident /
|
||||||
|
scoped / stateless). Session tracking, gap handling, memory extraction
|
||||||
|
on dismiss. **Tool meta-tool v1:** `sw.actions.list()` → LLM tool
|
||||||
|
definitions. AI calls extension actions, results flow back into chat.
|
||||||
|
|
||||||
|
**v0.13.3 — Notes Integration:**
|
||||||
|
Contributes "AI Summarize" / "AI Translate" / "AI Fix Grammar" to
|
||||||
|
`notes:toolbar-actions`. Contributes `/ai` to `notes:slash-commands`.
|
||||||
|
Uses `notes.get` / `notes.search` actions for context.
|
||||||
|
|
||||||
|
**v0.13.7 — Tool Meta-Tool Hardening:**
|
||||||
|
Three-tier tool scoping: admin blocklist (global), folder allowed_tools
|
||||||
|
(space-level), persona allow/deny (character-level). Tool result
|
||||||
|
rendering through `sw.renderers`. Rate limiting on tool calls. Audit
|
||||||
|
logging of tool use. Error handling (tool failure → graceful message).
|
||||||
|
|
||||||
|
**v0.13.8 — Integration Quality Gate:**
|
||||||
|
AI participant lifecycle tested end-to-end. Tool meta-tool demonstrated
|
||||||
|
with 3+ extensions. All three context archetypes tested. Persona
|
||||||
|
creation/sharing across visibility levels. Admin safety rails validated
|
||||||
|
(prompt injection resistance). Performance: completion latency measured.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.14.x — Sidecar Tier + Polish
|
||||||
|
|
||||||
|
| Version | Title |
|
||||||
|
|---------|-------|
|
||||||
|
| v0.14.0 | Sidecar Tier |
|
||||||
|
| v0.14.1 | Native Dialog Audit |
|
||||||
|
| v0.14.2 | Stability + Migration Tooling |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.0.0 Gate Criteria
|
||||||
|
|
||||||
|
- Notes reference extension shipped (all v0.11.x)
|
||||||
|
- Chat reference extension shipped (all v0.12.x)
|
||||||
|
- `llm-bridge` extends both notes and chat through composability
|
||||||
|
- Tool meta-tool demonstrated: AI uses 3+ extension actions in a
|
||||||
|
single conversation turn
|
||||||
|
- Notes and chat UI reviewed against design principles
|
||||||
|
- At least 2 panels consumed cross-package
|
||||||
|
- At least 2 slot contributions per host surface demonstrated
|
||||||
|
- Multi-persona context archetypes demonstrated
|
||||||
|
- Admin safety rails validated
|
||||||
|
- Note and conversation sharing functional end-to-end
|
||||||
|
- Headless E2E green on PG + SQLite
|
||||||
|
- All kernel Starlark modules documented
|
||||||
|
- All API routes covered by OpenAPI spec
|
||||||
|
- Upgrade path tested from v0.8.0 → v1.0.0
|
||||||
|
- Single-binary + Docker + K8s deployment paths documented
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions Log
|
||||||
|
|
||||||
|
| Decision | Rationale |
|
||||||
|
|----------|-----------|
|
||||||
|
| Panels as kernel primitive (v0.10.x) | Z-index coordination, drag/resize, layout negotiation are kernel concerns. |
|
||||||
|
| UI/UX redesign as first version in each reference series | Every feature builds on the visual foundation. |
|
||||||
|
| Notes before chat | Notes is simpler (no realtime) and proves storage/rendering/composability. Chat adds realtime + llm-bridge story. |
|
||||||
|
| Chat human-to-human first | AI is an extension concern. Keeps chat testable and usable standalone. |
|
||||||
|
| Folder attributes as extension bridge | Chat stores attributes it doesn't understand. llm-bridge contributes definitions. Zero coupling. |
|
||||||
|
| Action registry as tool registry (meta-tool) | Dynamic, zero-config AI tool-use. Installed extensions = AI capabilities. Uniquely Armature. |
|
||||||
|
| Layered prompt architecture (6 layers) | Admin safety not overridable. Clear separation: platform → space → character → context → tools → user. |
|
||||||
|
| Personas in llm-bridge, not chat | Chat sees AI as just another participant_type. Persona identity is llm-bridge's concern. |
|
||||||
|
| Background images via CSS filter | No server-side processing. `filter: blur() brightness() saturate()` + `opacity` handles any image. |
|
||||||
|
| Soft panel deps for notes↔chat | Runtime-resolved, no circular dependency. Both function independently. |
|
||||||
|
| llm-bridge after notes + chat | Proves the composability hooks work without being designed for a specific consumer. |
|
||||||
21
server/database/migrations/postgres/017_package_adoption.sql
Normal file
21
server/database/migrations/postgres/017_package_adoption.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Armature — 017 Package Adoption + Role Catalog
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
-- Add adoptable flag and adoption lineage to packages
|
||||||
|
ALTER TABLE packages ADD COLUMN IF NOT EXISTS adoptable BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE packages ADD COLUMN IF NOT EXISTS adopted_from TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_adoptable ON packages(adoptable) WHERE adoptable = true;
|
||||||
|
|
||||||
|
-- Team role catalog: known roles sourced from adopted packages
|
||||||
|
CREATE TABLE IF NOT EXISTS team_role_catalog (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(50) NOT NULL,
|
||||||
|
source_package_id TEXT REFERENCES packages(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(team_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_role_catalog_team ON team_role_catalog(team_id);
|
||||||
21
server/database/migrations/sqlite/017_package_adoption.sql
Normal file
21
server/database/migrations/sqlite/017_package_adoption.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- ==========================================
|
||||||
|
-- Armature — 017 Package Adoption + Role Catalog (SQLite)
|
||||||
|
-- ==========================================
|
||||||
|
|
||||||
|
-- Add adoptable flag and adoption lineage to packages
|
||||||
|
ALTER TABLE packages ADD COLUMN adoptable INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE packages ADD COLUMN adopted_from TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packages_adoptable ON packages(adoptable);
|
||||||
|
|
||||||
|
-- Team role catalog: known roles sourced from adopted packages
|
||||||
|
CREATE TABLE IF NOT EXISTS team_role_catalog (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
source_package_id TEXT REFERENCES packages(id) ON DELETE SET NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(team_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_team_role_catalog_team ON team_role_catalog(team_id);
|
||||||
284
server/handlers/package_adopt.go
Normal file
284
server/handlers/package_adopt.go
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"armature/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PackageAdoptHandler manages team package adoption endpoints.
|
||||||
|
type PackageAdoptHandler struct {
|
||||||
|
stores store.Stores
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPackageAdoptHandler(s store.Stores) *PackageAdoptHandler {
|
||||||
|
return &PackageAdoptHandler{stores: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdoptPackage clones a global adoptable package into the team.
|
||||||
|
// POST /api/v1/teams/:teamId/packages/:id/adopt
|
||||||
|
func (h *PackageAdoptHandler) AdoptPackage(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
teamID := c.Param("teamId")
|
||||||
|
sourceID := c.Param("id")
|
||||||
|
userID := c.GetString("user_id")
|
||||||
|
|
||||||
|
// Load source package
|
||||||
|
src, err := h.stores.Packages.Get(ctx, sourceID)
|
||||||
|
if err != nil || src == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify adoptable + global
|
||||||
|
if !src.Adoptable {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "package is not adoptable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if src.Scope != "global" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "only global packages can be adopted"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: check if already adopted
|
||||||
|
existing, err := h.stores.Packages.GetByAdoptedFrom(ctx, sourceID, teamID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check adoption"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
// Already adopted — return the existing record
|
||||||
|
roles, _ := h.stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"package": existing,
|
||||||
|
"roles_added": filterRolesBySource(roles, existing.ID),
|
||||||
|
"already_adopted": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate adopted package ID: {sourceID}--{teamID[:8]}
|
||||||
|
shortTeam := teamID
|
||||||
|
if len(shortTeam) > 8 {
|
||||||
|
shortTeam = shortTeam[:8]
|
||||||
|
}
|
||||||
|
adoptedID := sourceID + "--" + shortTeam
|
||||||
|
|
||||||
|
// Create team-scoped package row referencing the original
|
||||||
|
adoptedPkg := &store.PackageRegistration{
|
||||||
|
ID: adoptedID,
|
||||||
|
Title: src.Title,
|
||||||
|
Type: src.Type,
|
||||||
|
Version: src.Version,
|
||||||
|
Description: src.Description,
|
||||||
|
Author: src.Author,
|
||||||
|
Tier: src.Tier,
|
||||||
|
IsSystem: false,
|
||||||
|
Scope: "team",
|
||||||
|
TeamID: &teamID,
|
||||||
|
InstalledBy: &userID,
|
||||||
|
Manifest: src.Manifest,
|
||||||
|
Enabled: true,
|
||||||
|
Status: "active",
|
||||||
|
SchemaVersion: src.SchemaVersion,
|
||||||
|
PackageSettings: src.PackageSettings,
|
||||||
|
Source: "extension",
|
||||||
|
Adoptable: false,
|
||||||
|
AdoptedFrom: &sourceID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Packages.Create(ctx, adoptedPkg); err != nil {
|
||||||
|
log.Printf("[packages] adopt: failed to create adopted package %s: %v", adoptedID, err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to adopt package"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-populate role catalog from requires_roles
|
||||||
|
var rolesAdded []string
|
||||||
|
if rr, ok := src.Manifest["requires_roles"].([]any); ok {
|
||||||
|
for _, v := range rr {
|
||||||
|
if role, ok := v.(string); ok && role != "" {
|
||||||
|
if err := h.stores.Teams.AddRoleToCatalog(ctx, teamID, role, adoptedID); err != nil {
|
||||||
|
log.Printf("[packages] adopt: failed to add role %q to catalog: %v", role, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rolesAdded = append(rolesAdded, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If source is a workflow package, clone the workflow + stages
|
||||||
|
if src.Type == "workflow" {
|
||||||
|
h.cloneWorkflowForAdoption(c, src, adoptedPkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[packages] adopted %s → %s for team %s (roles: %v)", sourceID, adoptedID, teamID, rolesAdded)
|
||||||
|
c.JSON(http.StatusCreated, gin.H{
|
||||||
|
"package": adoptedPkg,
|
||||||
|
"roles_added": rolesAdded,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneWorkflowForAdoption copies the workflow definition from the source
|
||||||
|
// package into a team-scoped workflow. Best-effort — logs errors but does
|
||||||
|
// not abort the adoption.
|
||||||
|
func (h *PackageAdoptHandler) cloneWorkflowForAdoption(c *gin.Context, src, adopted *store.PackageRegistration) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
teamID := *adopted.TeamID
|
||||||
|
|
||||||
|
// Extract workflow_definition from manifest
|
||||||
|
wfDef, ok := src.Manifest["workflow_definition"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[packages] adopt: no workflow_definition in manifest for %s", src.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the InstallWorkflowFromManifest path if available, or
|
||||||
|
// fall back to looking up the workflow by package ID
|
||||||
|
wfSlug, _ := wfDef["slug"].(string)
|
||||||
|
if wfSlug == "" {
|
||||||
|
wfSlug = src.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the global workflow installed from this package
|
||||||
|
wf, err := h.stores.Workflows.GetBySlug(ctx, nil, wfSlug)
|
||||||
|
if err != nil || wf == nil {
|
||||||
|
log.Printf("[packages] adopt: source workflow %q not found, skipping clone", wfSlug)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stages, err := h.stores.Workflows.ListStages(ctx, wf.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[packages] adopt: failed to load stages for %s: %v", wf.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone workflow into team scope
|
||||||
|
clone := *wf
|
||||||
|
clone.ID = ""
|
||||||
|
clone.TeamID = &teamID
|
||||||
|
clone.IsActive = false
|
||||||
|
clone.Version = 0
|
||||||
|
clone.CreatedBy = c.GetString("user_id")
|
||||||
|
|
||||||
|
if err := h.stores.Workflows.Create(ctx, &clone); err != nil {
|
||||||
|
// Slug conflict — append short ID
|
||||||
|
clone.Slug = wfSlug + "-" + store.NewID()[:6]
|
||||||
|
if err2 := h.stores.Workflows.Create(ctx, &clone); err2 != nil {
|
||||||
|
log.Printf("[packages] adopt: failed to clone workflow: %v", err2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone stages
|
||||||
|
for _, st := range stages {
|
||||||
|
cloneSt := st
|
||||||
|
cloneSt.ID = ""
|
||||||
|
cloneSt.WorkflowID = clone.ID
|
||||||
|
if err := h.stores.Workflows.CreateStage(ctx, &cloneSt); err != nil {
|
||||||
|
log.Printf("[packages] adopt: failed to clone stage %s: %v", st.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("[packages] adopt: cloned workflow %s → %s for team %s", wf.ID, clone.ID, teamID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdoptablePackages returns global adoptable packages, annotating which
|
||||||
|
// ones have already been adopted by this team.
|
||||||
|
// GET /api/v1/teams/:teamId/packages/adoptable
|
||||||
|
func (h *PackageAdoptHandler) ListAdoptablePackages(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
teamID := c.Param("teamId")
|
||||||
|
|
||||||
|
pkgs, err := h.stores.Packages.ListAdoptable(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type adoptableItem struct {
|
||||||
|
store.PackageRegistration
|
||||||
|
Adopted bool `json:"adopted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []adoptableItem
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
item := adoptableItem{PackageRegistration: pkg}
|
||||||
|
existing, _ := h.stores.Packages.GetByAdoptedFrom(ctx, pkg.ID, teamID)
|
||||||
|
item.Adopted = existing != nil
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
result = []adoptableItem{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnadoptPackage removes a team's adopted package and cleans up role catalog.
|
||||||
|
// DELETE /api/v1/teams/:teamId/packages/:id/unadopt
|
||||||
|
func (h *PackageAdoptHandler) UnadoptPackage(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
teamID := c.Param("teamId")
|
||||||
|
pkgID := c.Param("id")
|
||||||
|
|
||||||
|
pkg, err := h.stores.Packages.Get(ctx, pkgID)
|
||||||
|
if err != nil || pkg == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's an adopted package belonging to this team
|
||||||
|
if pkg.AdoptedFrom == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "package was not adopted"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pkg.TeamID == nil || *pkg.TeamID != teamID {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "package does not belong to this team"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up role catalog entries sourced from this package
|
||||||
|
if err := h.stores.Teams.RemoveRoleCatalogBySource(ctx, teamID, pkgID); err != nil {
|
||||||
|
log.Printf("[packages] unadopt: failed to clean role catalog: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the adopted package row
|
||||||
|
if err := h.stores.Packages.Delete(ctx, pkgID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove package"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[packages] unadopted %s from team %s", pkgID, teamID)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "package unadopted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRoleCatalog returns all known roles for a team.
|
||||||
|
// GET /api/v1/teams/:teamId/roles/catalog
|
||||||
|
func (h *PackageAdoptHandler) ListRoleCatalog(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
teamID := c.Param("teamId")
|
||||||
|
|
||||||
|
roles, err := h.stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list role catalog"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if roles == nil {
|
||||||
|
roles = []store.TeamRoleCatalogEntry{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": roles})
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterRolesBySource returns only roles from a specific package.
|
||||||
|
func filterRolesBySource(roles []store.TeamRoleCatalogEntry, pkgID string) []string {
|
||||||
|
var result []string
|
||||||
|
for _, r := range roles {
|
||||||
|
if r.SourcePackageID == pkgID {
|
||||||
|
result = append(result, r.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
353
server/handlers/package_adopt_test.go
Normal file
353
server/handlers/package_adopt_test.go
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"armature/database"
|
||||||
|
"armature/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Manifest: adoptable parsing ──────────────
|
||||||
|
|
||||||
|
func TestValidateManifest_Adoptable(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "adopt-wf",
|
||||||
|
"title": "Adoptable Workflow",
|
||||||
|
"type": "workflow",
|
||||||
|
"adoptable": true,
|
||||||
|
"workflow_definition": map[string]any{
|
||||||
|
"slug": "adopt-wf",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
info, err := ValidateManifest(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !info.Adoptable {
|
||||||
|
t.Error("expected Adoptable to be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_Adoptable_Default(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "plain-surface",
|
||||||
|
"title": "Plain Surface",
|
||||||
|
"type": "surface",
|
||||||
|
}
|
||||||
|
info, err := ValidateManifest(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if info.Adoptable {
|
||||||
|
t.Error("expected Adoptable to be false by default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_Adoptable_LibraryRejected(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "adopt-lib",
|
||||||
|
"title": "Adoptable Library",
|
||||||
|
"type": "library",
|
||||||
|
"adoptable": true,
|
||||||
|
"exports": []any{"module_a"},
|
||||||
|
}
|
||||||
|
_, err := ValidateManifest(m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for adoptable library")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_Adoptable_TestRunnerRejected(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "adopt-runner",
|
||||||
|
"title": "Adoptable Runner",
|
||||||
|
"type": "test-runner",
|
||||||
|
"adoptable": true,
|
||||||
|
}
|
||||||
|
_, err := ValidateManifest(m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for adoptable test-runner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManifest_Adoptable_SurfaceAllowed(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"id": "adopt-surface",
|
||||||
|
"title": "Adoptable Surface",
|
||||||
|
"type": "surface",
|
||||||
|
"adoptable": true,
|
||||||
|
}
|
||||||
|
info, err := ValidateManifest(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !info.Adoptable {
|
||||||
|
t.Error("expected Adoptable to be true for surface")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store: role catalog CRUD ──────────────
|
||||||
|
|
||||||
|
// seedMinimalPackage creates a minimal package for FK references in tests.
|
||||||
|
func seedMinimalPackage(t *testing.T, stores store.Stores, id string) {
|
||||||
|
t.Helper()
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: id, Title: id, Type: "surface", Version: "1.0.0",
|
||||||
|
Scope: "global", Tier: "browser", Manifest: map[string]any{"id": id, "title": id},
|
||||||
|
Enabled: true, Status: "active", Source: "extension",
|
||||||
|
}
|
||||||
|
if err := stores.Packages.Create(context.Background(), pkg); err != nil {
|
||||||
|
t.Fatalf("seed minimal package %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleCatalog_AddAndList(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
teamID, _, _ := seedTeamAndMember(t, stores)
|
||||||
|
pkgID := "rc-pkg-" + store.NewID()[:6]
|
||||||
|
seedMinimalPackage(t, stores, pkgID)
|
||||||
|
|
||||||
|
// Add two roles
|
||||||
|
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgID); err != nil {
|
||||||
|
t.Fatalf("AddRoleToCatalog: %v", err)
|
||||||
|
}
|
||||||
|
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "reviewer", pkgID); err != nil {
|
||||||
|
t.Fatalf("AddRoleToCatalog: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, err := stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListRoleCatalog: %v", err)
|
||||||
|
}
|
||||||
|
if len(roles) != 2 {
|
||||||
|
t.Fatalf("expected 2 roles, got %d: %v", len(roles), roles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleCatalog_AddIdempotent(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
teamID, _, _ := seedTeamAndMember(t, stores)
|
||||||
|
pkgA := "rc-idem-a-" + store.NewID()[:6]
|
||||||
|
pkgB := "rc-idem-b-" + store.NewID()[:6]
|
||||||
|
seedMinimalPackage(t, stores, pkgA)
|
||||||
|
seedMinimalPackage(t, stores, pkgB)
|
||||||
|
|
||||||
|
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgA); err != nil {
|
||||||
|
t.Fatalf("first add: %v", err)
|
||||||
|
}
|
||||||
|
// Same role from different source — should be idempotent (unique on team_id, role)
|
||||||
|
if err := stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgB); err != nil {
|
||||||
|
t.Fatalf("idempotent add should not error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, _ := stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||||
|
if len(roles) != 1 {
|
||||||
|
t.Errorf("expected 1 role after idempotent add, got %d", len(roles))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleCatalog_RemoveBySource(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
teamID, _, _ := seedTeamAndMember(t, stores)
|
||||||
|
pkgRm := "rc-rm-" + store.NewID()[:6]
|
||||||
|
pkgKeep := "rc-keep-" + store.NewID()[:6]
|
||||||
|
seedMinimalPackage(t, stores, pkgRm)
|
||||||
|
seedMinimalPackage(t, stores, pkgKeep)
|
||||||
|
|
||||||
|
stores.Teams.AddRoleToCatalog(ctx, teamID, "approver", pkgRm)
|
||||||
|
stores.Teams.AddRoleToCatalog(ctx, teamID, "reviewer", pkgRm)
|
||||||
|
stores.Teams.AddRoleToCatalog(ctx, teamID, "admin-role", pkgKeep)
|
||||||
|
|
||||||
|
if err := stores.Teams.RemoveRoleCatalogBySource(ctx, teamID, pkgRm); err != nil {
|
||||||
|
t.Fatalf("RemoveRoleCatalogBySource: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
roles, _ := stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||||
|
if len(roles) != 1 {
|
||||||
|
t.Fatalf("expected 1 role remaining, got %d: %v", len(roles), roles)
|
||||||
|
}
|
||||||
|
if roles[0].Role != "admin-role" {
|
||||||
|
t.Errorf("expected 'admin-role' remaining, got %q", roles[0].Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store: package adoption ──────────────
|
||||||
|
|
||||||
|
func seedAdoptablePackage(t *testing.T, stores store.Stores, id string, roles []string) {
|
||||||
|
t.Helper()
|
||||||
|
manifest := map[string]any{
|
||||||
|
"id": id,
|
||||||
|
"title": "Adoptable " + id,
|
||||||
|
"type": "surface",
|
||||||
|
}
|
||||||
|
if len(roles) > 0 {
|
||||||
|
rr := make([]any, len(roles))
|
||||||
|
for i, r := range roles {
|
||||||
|
rr[i] = r
|
||||||
|
}
|
||||||
|
manifest["requires_roles"] = rr
|
||||||
|
}
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: id,
|
||||||
|
Title: "Adoptable " + id,
|
||||||
|
Type: "surface",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Tier: "browser",
|
||||||
|
Scope: "global",
|
||||||
|
Manifest: manifest,
|
||||||
|
Enabled: true,
|
||||||
|
Status: "active",
|
||||||
|
Source: "extension",
|
||||||
|
Adoptable: true,
|
||||||
|
}
|
||||||
|
if err := stores.Packages.Create(context.Background(), pkg); err != nil {
|
||||||
|
t.Fatalf("seed adoptable package: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdoptPackage_Success(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
pkgID := "adopt-test-" + store.NewID()[:6]
|
||||||
|
seedAdoptablePackage(t, stores, pkgID, []string{"approver", "reviewer"})
|
||||||
|
|
||||||
|
// Set up handler
|
||||||
|
h := NewPackageAdoptHandler(stores)
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, r := gin.CreateTestContext(w)
|
||||||
|
r.POST("/teams/:teamId/packages/:id/adopt", func(c *gin.Context) {
|
||||||
|
c.Set("user_id", userID)
|
||||||
|
h.AdoptPackage(c)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/teams/"+teamID+"/packages/"+pkgID+"/adopt", nil)
|
||||||
|
c.Request = req
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
|
// Verify roles_added
|
||||||
|
rolesAdded, ok := resp["roles_added"].([]any)
|
||||||
|
if !ok || len(rolesAdded) != 2 {
|
||||||
|
t.Errorf("expected 2 roles_added, got %v", resp["roles_added"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify team package was created
|
||||||
|
shortTeam := teamID
|
||||||
|
if len(shortTeam) > 8 {
|
||||||
|
shortTeam = shortTeam[:8]
|
||||||
|
}
|
||||||
|
adopted, _ := stores.Packages.Get(ctx, pkgID+"--"+shortTeam)
|
||||||
|
if adopted == nil {
|
||||||
|
t.Fatal("adopted package not found in DB")
|
||||||
|
}
|
||||||
|
if adopted.Scope != "team" {
|
||||||
|
t.Errorf("expected scope 'team', got %q", adopted.Scope)
|
||||||
|
}
|
||||||
|
if adopted.AdoptedFrom == nil || *adopted.AdoptedFrom != pkgID {
|
||||||
|
t.Errorf("expected adopted_from=%q, got %v", pkgID, adopted.AdoptedFrom)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify role catalog populated
|
||||||
|
roles, _ := stores.Teams.ListRoleCatalog(ctx, teamID)
|
||||||
|
found := 0
|
||||||
|
for _, r := range roles {
|
||||||
|
if r.SourcePackageID == pkgID+"--"+shortTeam {
|
||||||
|
found++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found != 2 {
|
||||||
|
t.Errorf("expected 2 catalog roles from adoption, got %d", found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdoptPackage_Idempotent(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
|
||||||
|
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
pkgID := "adopt-idem-" + store.NewID()[:6]
|
||||||
|
seedAdoptablePackage(t, stores, pkgID, nil)
|
||||||
|
|
||||||
|
h := NewPackageAdoptHandler(stores)
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
doAdopt := func() int {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
_, r := gin.CreateTestContext(w)
|
||||||
|
r.POST("/teams/:teamId/packages/:id/adopt", func(c *gin.Context) {
|
||||||
|
c.Set("user_id", userID)
|
||||||
|
h.AdoptPackage(c)
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/teams/"+teamID+"/packages/"+pkgID+"/adopt", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
code1 := doAdopt()
|
||||||
|
if code1 != http.StatusCreated {
|
||||||
|
t.Fatalf("first adopt: expected 201, got %d", code1)
|
||||||
|
}
|
||||||
|
code2 := doAdopt()
|
||||||
|
if code2 != http.StatusOK {
|
||||||
|
t.Fatalf("second adopt: expected 200, got %d", code2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdoptPackage_NotAdoptable(t *testing.T) {
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
stores := testStores(t)
|
||||||
|
|
||||||
|
teamID, userID, _ := seedTeamAndMember(t, stores)
|
||||||
|
|
||||||
|
// Seed a non-adoptable package
|
||||||
|
pkg := &store.PackageRegistration{
|
||||||
|
ID: "no-adopt-" + store.NewID()[:6], Title: "Not Adoptable",
|
||||||
|
Type: "surface", Version: "1.0.0", Tier: "browser", Scope: "global",
|
||||||
|
Manifest: map[string]any{"id": "no-adopt", "title": "Not Adoptable"},
|
||||||
|
Enabled: true, Status: "active", Source: "extension",
|
||||||
|
Adoptable: false,
|
||||||
|
}
|
||||||
|
if err := stores.Packages.Create(context.Background(), pkg); err != nil {
|
||||||
|
t.Fatalf("seed non-adoptable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewPackageAdoptHandler(stores)
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
_, r := gin.CreateTestContext(w)
|
||||||
|
r.POST("/teams/:teamId/packages/:id/adopt", func(c *gin.Context) {
|
||||||
|
c.Set("user_id", userID)
|
||||||
|
h.AdoptPackage(c)
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/teams/"+teamID+"/packages/"+pkg.ID+"/adopt", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 for non-adoptable, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ type ManifestInfo struct {
|
|||||||
UserPermissions []string // user-facing permissions declared by the extension
|
UserPermissions []string // user-facing permissions declared by the extension
|
||||||
GatePermission string // if set, checks this user permission before calling on_request
|
GatePermission string // if set, checks this user permission before calling on_request
|
||||||
RequiresRoles []string // team roles needed to access this package (advisory, OR semantics)
|
RequiresRoles []string // team roles needed to access this package (advisory, OR semantics)
|
||||||
|
Adoptable bool // if true, teams can adopt this package to get a team-scoped copy
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateManifest parses a manifest map and validates all required fields,
|
// ValidateManifest parses a manifest map and validates all required fields,
|
||||||
@@ -178,6 +179,11 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.9.4: adoptable — teams can adopt this package to get a team-scoped copy
|
||||||
|
if v, ok := manifest["adoptable"].(bool); ok {
|
||||||
|
info.Adoptable = v
|
||||||
|
}
|
||||||
|
|
||||||
info.SchemaVersion = ParseSchemaVersion(manifest)
|
info.SchemaVersion = ParseSchemaVersion(manifest)
|
||||||
|
|
||||||
// ── Type-specific constraints ────────────────────────────────
|
// ── Type-specific constraints ────────────────────────────────
|
||||||
@@ -216,10 +222,16 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
|
|||||||
if info.HasRoute {
|
if info.HasRoute {
|
||||||
return nil, fmt.Errorf("library packages cannot have a route")
|
return nil, fmt.Errorf("library packages cannot have a route")
|
||||||
}
|
}
|
||||||
|
if info.Adoptable {
|
||||||
|
return nil, fmt.Errorf("library packages cannot be adoptable")
|
||||||
|
}
|
||||||
case "test-runner":
|
case "test-runner":
|
||||||
// Test runners are surface-like packages discovered by type.
|
// Test runners are surface-like packages discovered by type.
|
||||||
// They are not shown in navigation (extensionNavItems filters for surface/full).
|
// They are not shown in navigation (extensionNavItems filters for surface/full).
|
||||||
// They may have a route but it's optional — the registry surface provides access.
|
// They may have a route but it's optional — the registry surface provides access.
|
||||||
|
if info.Adoptable {
|
||||||
|
return nil, fmt.Errorf("test-runner packages cannot be adoptable")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return info, nil
|
return info, nil
|
||||||
|
|||||||
@@ -591,6 +591,18 @@ func (h *PackageHandler) registerPackage(c *gin.Context, pkgID string, mInfo *Ma
|
|||||||
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
|
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
|
||||||
log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
|
log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.9.4: persist adoptable flag (Update doesn't cover it — separate column)
|
||||||
|
if mInfo.Adoptable {
|
||||||
|
q := `UPDATE packages SET adoptable = true WHERE id = $1`
|
||||||
|
if !database.IsPostgres() {
|
||||||
|
q = `UPDATE packages SET adoptable = 1 WHERE id = ?`
|
||||||
|
}
|
||||||
|
if _, err := database.DB.ExecContext(c.Request.Context(), q, pkgID); err != nil {
|
||||||
|
log.Printf("[packages] Failed to set adoptable for %s: %v", pkgID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return preservedEnabled, nil
|
return preservedEnabled, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,13 @@ func (h *WorkflowHandler) requireTeamWorkflow(c *gin.Context) bool {
|
|||||||
// AdoptTeamWorkflow clones a global (team_id=NULL) workflow into this team.
|
// AdoptTeamWorkflow clones a global (team_id=NULL) workflow into this team.
|
||||||
// The global original is left untouched so other teams can also adopt it.
|
// The global original is left untouched so other teams can also adopt it.
|
||||||
// POST /api/v1/teams/:teamId/workflows/:id/adopt
|
// POST /api/v1/teams/:teamId/workflows/:id/adopt
|
||||||
|
//
|
||||||
|
// Deprecated: Use POST /api/v1/teams/:teamId/packages/:id/adopt instead.
|
||||||
|
// This endpoint will be removed in a future version.
|
||||||
func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) {
|
func (h *WorkflowHandler) AdoptTeamWorkflow(c *gin.Context) {
|
||||||
|
c.Header("X-Deprecated", "Use POST /api/v1/teams/:teamId/packages/:id/adopt instead")
|
||||||
|
log.Printf("[workflows] DEPRECATED: AdoptTeamWorkflow called for team %s — use POST /teams/:teamId/packages/:id/adopt", c.Param("teamId"))
|
||||||
|
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
teamID := c.Param("teamId")
|
teamID := c.Param("teamId")
|
||||||
srcID := c.Param("id")
|
srcID := c.Param("id")
|
||||||
|
|||||||
@@ -660,6 +660,13 @@ func main() {
|
|||||||
teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage)
|
teamScoped.POST("/packages/install", teamPkgH.InstallTeamPackage)
|
||||||
teamScoped.DELETE("/packages/:id", teamPkgH.DeleteTeamPackage)
|
teamScoped.DELETE("/packages/:id", teamPkgH.DeleteTeamPackage)
|
||||||
|
|
||||||
|
// Team package adoption
|
||||||
|
adoptH := handlers.NewPackageAdoptHandler(stores)
|
||||||
|
teamScoped.POST("/packages/:id/adopt", adoptH.AdoptPackage)
|
||||||
|
teamScoped.GET("/packages/adoptable", adoptH.ListAdoptablePackages)
|
||||||
|
teamScoped.DELETE("/packages/:id/unadopt", adoptH.UnadoptPackage)
|
||||||
|
teamScoped.GET("/roles/catalog", adoptH.ListRoleCatalog)
|
||||||
|
|
||||||
// Team package settings — cascade overrides
|
// Team package settings — cascade overrides
|
||||||
teamPkgSettingsH := handlers.NewTeamPackageSettingsHandler(stores)
|
teamPkgSettingsH := handlers.NewTeamPackageSettingsHandler(stores)
|
||||||
teamScoped.GET("/packages/:id/settings", teamPkgSettingsH.GetTeamPackageSettings)
|
teamScoped.GET("/packages/:id/settings", teamPkgSettingsH.GetTeamPackageSettings)
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ func (m *mockPackageStore) SetPackageSettings(context.Context, string, json.RawM
|
|||||||
func (m *mockPackageStore) GetTeamSettings(context.Context, string, string) (json.RawMessage, error) { return nil, nil }
|
func (m *mockPackageStore) GetTeamSettings(context.Context, string, string) (json.RawMessage, error) { return nil, nil }
|
||||||
func (m *mockPackageStore) SetTeamSettings(context.Context, string, string, json.RawMessage) error { return nil }
|
func (m *mockPackageStore) SetTeamSettings(context.Context, string, string, json.RawMessage) error { return nil }
|
||||||
func (m *mockPackageStore) DeleteTeamSettings(context.Context, string, string) error { return nil }
|
func (m *mockPackageStore) DeleteTeamSettings(context.Context, string, string) error { return nil }
|
||||||
|
func (m *mockPackageStore) ListAdoptable(context.Context) ([]store.PackageRegistration, error) { return nil, nil }
|
||||||
|
func (m *mockPackageStore) GetByAdoptedFrom(context.Context, string, string) (*store.PackageRegistration, error) { return nil, nil }
|
||||||
|
|
||||||
// ── test helpers ─────────────────────────────────────────────
|
// ── test helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,23 @@ type TeamStore interface {
|
|||||||
|
|
||||||
// RemoveAllUserRoles deletes all additional roles for a member (cleanup on removal).
|
// RemoveAllUserRoles deletes all additional roles for a member (cleanup on removal).
|
||||||
RemoveAllUserRoles(ctx context.Context, teamID, userID string) error
|
RemoveAllUserRoles(ctx context.Context, teamID, userID string) error
|
||||||
|
|
||||||
|
// ── v0.9.4 — Team Role Catalog ──
|
||||||
|
|
||||||
|
// AddRoleToCatalog registers a known role for the team. Idempotent.
|
||||||
|
AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error
|
||||||
|
|
||||||
|
// ListRoleCatalog returns all known roles for a team.
|
||||||
|
ListRoleCatalog(ctx context.Context, teamID string) ([]TeamRoleCatalogEntry, error)
|
||||||
|
|
||||||
|
// RemoveRoleCatalogBySource removes all catalog entries sourced from a package.
|
||||||
|
RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeamRoleCatalogEntry represents a known role sourced from an adopted package.
|
||||||
|
type TeamRoleCatalogEntry struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
SourcePackageID string `json:"source_package_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
@@ -93,6 +93,14 @@ type PackageStore interface {
|
|||||||
|
|
||||||
// DeleteTeamSettings removes team-scoped overrides, reverting to global defaults.
|
// DeleteTeamSettings removes team-scoped overrides, reverting to global defaults.
|
||||||
DeleteTeamSettings(ctx context.Context, pkgID, teamID string) error
|
DeleteTeamSettings(ctx context.Context, pkgID, teamID string) error
|
||||||
|
|
||||||
|
// ── v0.9.4 — Package Adoption ───────────────
|
||||||
|
|
||||||
|
// ListAdoptable returns global, adoptable, enabled packages.
|
||||||
|
ListAdoptable(ctx context.Context) ([]PackageRegistration, error)
|
||||||
|
|
||||||
|
// GetByAdoptedFrom returns a team's adopted copy of a source package, or nil.
|
||||||
|
GetByAdoptedFrom(ctx context.Context, sourceID, teamID string) (*PackageRegistration, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PackageRegistration is a row from the packages table.
|
// PackageRegistration is a row from the packages table.
|
||||||
@@ -114,6 +122,8 @@ type PackageRegistration struct {
|
|||||||
SchemaVersion int `json:"schema_version" db:"schema_version"`
|
SchemaVersion int `json:"schema_version" db:"schema_version"`
|
||||||
PackageSettings json.RawMessage `json:"package_settings" db:"package_settings"`
|
PackageSettings json.RawMessage `json:"package_settings" db:"package_settings"`
|
||||||
Source string `json:"source" db:"source"`
|
Source string `json:"source" db:"source"`
|
||||||
|
Adoptable bool `json:"adoptable" db:"adoptable"`
|
||||||
|
AdoptedFrom *string `json:"adopted_from,omitempty" db:"adopted_from"`
|
||||||
InstalledAt string `json:"installed_at" db:"installed_at"`
|
InstalledAt string `json:"installed_at" db:"installed_at"`
|
||||||
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
UpdatedAt string `json:"updated_at" db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,15 +107,15 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio
|
|||||||
return DB.QueryRowContext(ctx, `
|
return DB.QueryRowContext(ctx, `
|
||||||
INSERT INTO packages (id, title, type, version, description, author, tier,
|
INSERT INTO packages (id, title, type, version, description, author, tier,
|
||||||
is_system, scope, team_id, installed_by, manifest, enabled, status,
|
is_system, scope, team_id, installed_by, manifest, enabled, status,
|
||||||
schema_version, package_settings, source)
|
schema_version, package_settings, source, adoptable, adopted_from)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
|
||||||
RETURNING installed_at, updated_at`,
|
RETURNING installed_at, updated_at`,
|
||||||
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||||
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
||||||
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
||||||
manifestJSON, pkg.Enabled, pkg.Status,
|
manifestJSON, pkg.Enabled, pkg.Status,
|
||||||
pkg.SchemaVersion, defaultJSON(pkg.PackageSettings),
|
pkg.SchemaVersion, defaultJSON(pkg.PackageSettings),
|
||||||
pkg.Source,
|
pkg.Source, pkg.Adoptable, nullStrPtr(pkg.AdoptedFrom),
|
||||||
).Scan(&pkg.InstalledAt, &pkg.UpdatedAt)
|
).Scan(&pkg.InstalledAt, &pkg.UpdatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
var result []store.UserPackage
|
var result []store.UserPackage
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var up store.UserPackage
|
var up store.UserPackage
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy, adoptedFrom sql.NullString
|
||||||
var manifestJSON []byte
|
var manifestJSON []byte
|
||||||
var pkgSettings []byte
|
var pkgSettings []byte
|
||||||
var userEnabled sql.NullBool
|
var userEnabled sql.NullBool
|
||||||
@@ -185,13 +185,15 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &up.Enabled, &up.Status,
|
&manifestJSON, &up.Enabled, &up.Status,
|
||||||
&up.SchemaVersion, &pkgSettings,
|
&up.SchemaVersion, &pkgSettings,
|
||||||
&up.Source, &up.InstalledAt, &up.UpdatedAt,
|
&up.Source, &up.Adoptable, &adoptedFrom,
|
||||||
|
&up.InstalledAt, &up.UpdatedAt,
|
||||||
&userEnabled, &userSettings,
|
&userEnabled, &userSettings,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
up.TeamID = NullableStringPtr(teamID)
|
up.TeamID = NullableStringPtr(teamID)
|
||||||
up.InstalledBy = NullableStringPtr(installedBy)
|
up.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
up.AdoptedFrom = NullableStringPtr(adoptedFrom)
|
||||||
json.Unmarshal(manifestJSON, &up.Manifest)
|
json.Unmarshal(manifestJSON, &up.Manifest)
|
||||||
up.PackageSettings = json.RawMessage(pkgSettings)
|
up.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
if userEnabled.Valid {
|
if userEnabled.Valid {
|
||||||
@@ -250,11 +252,12 @@ const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
|
|||||||
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
||||||
p.manifest, p.enabled, p.status,
|
p.manifest, p.enabled, p.status,
|
||||||
p.schema_version, p.package_settings,
|
p.schema_version, p.package_settings,
|
||||||
p.source, p.installed_at, p.updated_at`
|
p.source, p.adoptable, p.adopted_from,
|
||||||
|
p.installed_at, p.updated_at`
|
||||||
|
|
||||||
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
||||||
var pkg store.PackageRegistration
|
var pkg store.PackageRegistration
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy, adoptedFrom sql.NullString
|
||||||
var manifestJSON []byte
|
var manifestJSON []byte
|
||||||
var pkgSettings []byte
|
var pkgSettings []byte
|
||||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||||
@@ -263,7 +266,8 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
|
|||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &pkg.Enabled, &pkg.Status,
|
&manifestJSON, &pkg.Enabled, &pkg.Status,
|
||||||
&pkg.SchemaVersion, &pkgSettings,
|
&pkg.SchemaVersion, &pkgSettings,
|
||||||
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
|
&pkg.Source, &pkg.Adoptable, &adoptedFrom,
|
||||||
|
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -273,6 +277,7 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
|
|||||||
}
|
}
|
||||||
pkg.TeamID = NullableStringPtr(teamID)
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
|
||||||
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
||||||
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
return &pkg, nil
|
return &pkg, nil
|
||||||
@@ -288,7 +293,7 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
|
|||||||
var result []store.PackageRegistration
|
var result []store.PackageRegistration
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var pkg store.PackageRegistration
|
var pkg store.PackageRegistration
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy, adoptedFrom sql.NullString
|
||||||
var manifestJSON []byte
|
var manifestJSON []byte
|
||||||
var pkgSettings []byte
|
var pkgSettings []byte
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
@@ -297,12 +302,14 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
|
|||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &pkg.Enabled, &pkg.Status,
|
&manifestJSON, &pkg.Enabled, &pkg.Status,
|
||||||
&pkg.SchemaVersion, &pkgSettings,
|
&pkg.SchemaVersion, &pkgSettings,
|
||||||
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
|
&pkg.Source, &pkg.Adoptable, &adoptedFrom,
|
||||||
|
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pkg.TeamID = NullableStringPtr(teamID)
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
|
||||||
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
json.Unmarshal(manifestJSON, &pkg.Manifest)
|
||||||
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
result = append(result, pkg)
|
result = append(result, pkg)
|
||||||
@@ -397,6 +404,23 @@ func (s *PackageStore) DeleteTeamSettings(ctx context.Context, pkgID, teamID str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.9.4 — Package Adoption ───────────────
|
||||||
|
|
||||||
|
func (s *PackageStore) ListAdoptable(ctx context.Context) ([]store.PackageRegistration, error) {
|
||||||
|
return s.scanMany(ctx, `
|
||||||
|
SELECT `+pkgCols+`
|
||||||
|
FROM packages p
|
||||||
|
WHERE p.adoptable = true AND p.scope = 'global' AND p.enabled = true
|
||||||
|
ORDER BY p.title`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) GetByAdoptedFrom(ctx context.Context, sourceID, teamID string) (*store.PackageRegistration, error) {
|
||||||
|
return s.scanOne(ctx, `
|
||||||
|
SELECT `+pkgCols+`
|
||||||
|
FROM packages p
|
||||||
|
WHERE p.adopted_from = $1 AND p.team_id = $2`, sourceID, teamID)
|
||||||
|
}
|
||||||
|
|
||||||
// nullStrPtr converts *string to sql.NullString for nullable FK columns.
|
// nullStrPtr converts *string to sql.NullString for nullable FK columns.
|
||||||
func nullStrPtr(s *string) sql.NullString {
|
func nullStrPtr(s *string) sql.NullString {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"armature/models"
|
"armature/models"
|
||||||
|
"armature/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TeamStore struct{}
|
type TeamStore struct{}
|
||||||
@@ -393,3 +394,40 @@ func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID strin
|
|||||||
teamID, userID)
|
teamID, userID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.9.4 — Team Role Catalog ──
|
||||||
|
|
||||||
|
func (s *TeamStore) AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO team_role_catalog (id, team_id, role, source_package_id)
|
||||||
|
VALUES (gen_random_uuid(), $1, $2, $3)
|
||||||
|
ON CONFLICT (team_id, role) DO NOTHING`,
|
||||||
|
teamID, role, sourcePkgID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) ListRoleCatalog(ctx context.Context, teamID string) ([]store.TeamRoleCatalogEntry, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, `
|
||||||
|
SELECT role, COALESCE(source_package_id, '') FROM team_role_catalog
|
||||||
|
WHERE team_id = $1 ORDER BY role`, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var result []store.TeamRoleCatalogEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e store.TeamRoleCatalogEntry
|
||||||
|
if err := rows.Scan(&e.Role, &e.SourcePackageID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, e)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
DELETE FROM team_role_catalog WHERE team_id = $1 AND source_package_id = $2`,
|
||||||
|
teamID, sourcePkgID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,18 +109,22 @@ func (s *PackageStore) Create(ctx context.Context, pkg *store.PackageRegistratio
|
|||||||
if pkg.Status == "" {
|
if pkg.Status == "" {
|
||||||
pkg.Status = "active"
|
pkg.Status = "active"
|
||||||
}
|
}
|
||||||
|
adoptableInt := 0
|
||||||
|
if pkg.Adoptable {
|
||||||
|
adoptableInt = 1
|
||||||
|
}
|
||||||
_, err := DB.ExecContext(ctx, `
|
_, err := DB.ExecContext(ctx, `
|
||||||
INSERT INTO packages (id, title, type, version, description, author, tier,
|
INSERT INTO packages (id, title, type, version, description, author, tier,
|
||||||
is_system, scope, team_id, installed_by, manifest, enabled, status,
|
is_system, scope, team_id, installed_by, manifest, enabled, status,
|
||||||
schema_version, package_settings, source,
|
schema_version, package_settings, source, adoptable, adopted_from,
|
||||||
installed_at, updated_at)
|
installed_at, updated_at)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
pkg.ID, pkg.Title, pkg.Type, pkg.Version, pkg.Description, pkg.Author,
|
||||||
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
pkg.Tier, pkg.IsSystem, pkg.Scope,
|
||||||
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
nullStrPtr(pkg.TeamID), nullStrPtr(pkg.InstalledBy),
|
||||||
manifestJSON, pkg.Enabled, pkg.Status,
|
manifestJSON, pkg.Enabled, pkg.Status,
|
||||||
pkg.SchemaVersion, defaultJSON(pkg.PackageSettings),
|
pkg.SchemaVersion, defaultJSON(pkg.PackageSettings),
|
||||||
pkg.Source,
|
pkg.Source, adoptableInt, nullStrPtr(pkg.AdoptedFrom),
|
||||||
now.Format(timeFmt), now.Format(timeFmt),
|
now.Format(timeFmt), now.Format(timeFmt),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -184,11 +188,12 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
var result []store.UserPackage
|
var result []store.UserPackage
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var up store.UserPackage
|
var up store.UserPackage
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy, adoptedFrom sql.NullString
|
||||||
var manifestJSON string
|
var manifestJSON string
|
||||||
var pkgSettings string
|
var pkgSettings string
|
||||||
var enabledInt int
|
var enabledInt int
|
||||||
var isSystemInt int
|
var isSystemInt int
|
||||||
|
var adoptableInt int
|
||||||
var userEnabled sql.NullBool
|
var userEnabled sql.NullBool
|
||||||
var userSettings []byte
|
var userSettings []byte
|
||||||
|
|
||||||
@@ -198,15 +203,18 @@ func (s *PackageStore) ListForUser(ctx context.Context, userID string) ([]store.
|
|||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &enabledInt, &up.Status,
|
&manifestJSON, &enabledInt, &up.Status,
|
||||||
&up.SchemaVersion, &pkgSettings,
|
&up.SchemaVersion, &pkgSettings,
|
||||||
&up.Source, &up.InstalledAt, &up.UpdatedAt,
|
&up.Source, &adoptableInt, &adoptedFrom,
|
||||||
|
&up.InstalledAt, &up.UpdatedAt,
|
||||||
&userEnabled, &userSettings,
|
&userEnabled, &userSettings,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
up.IsSystem = isSystemInt != 0
|
up.IsSystem = isSystemInt != 0
|
||||||
up.Enabled = enabledInt != 0
|
up.Enabled = enabledInt != 0
|
||||||
|
up.Adoptable = adoptableInt != 0
|
||||||
up.TeamID = NullableStringPtr(teamID)
|
up.TeamID = NullableStringPtr(teamID)
|
||||||
up.InstalledBy = NullableStringPtr(installedBy)
|
up.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
up.AdoptedFrom = NullableStringPtr(adoptedFrom)
|
||||||
json.Unmarshal([]byte(manifestJSON), &up.Manifest)
|
json.Unmarshal([]byte(manifestJSON), &up.Manifest)
|
||||||
up.PackageSettings = json.RawMessage(pkgSettings)
|
up.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
if userEnabled.Valid {
|
if userEnabled.Valid {
|
||||||
@@ -263,22 +271,25 @@ const pkgCols = `p.id, p.title, p.type, p.version, p.description, p.author,
|
|||||||
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
p.tier, p.is_system, p.scope, p.team_id, p.installed_by,
|
||||||
p.manifest, p.enabled, p.status,
|
p.manifest, p.enabled, p.status,
|
||||||
p.schema_version, p.package_settings,
|
p.schema_version, p.package_settings,
|
||||||
p.source, p.installed_at, p.updated_at`
|
p.source, p.adoptable, p.adopted_from,
|
||||||
|
p.installed_at, p.updated_at`
|
||||||
|
|
||||||
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interface{}) (*store.PackageRegistration, error) {
|
||||||
var pkg store.PackageRegistration
|
var pkg store.PackageRegistration
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy, adoptedFrom sql.NullString
|
||||||
var manifestJSON string
|
var manifestJSON string
|
||||||
var pkgSettings string
|
var pkgSettings string
|
||||||
var enabledInt int
|
var enabledInt int
|
||||||
var isSystemInt int
|
var isSystemInt int
|
||||||
|
var adoptableInt int
|
||||||
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
err := DB.QueryRowContext(ctx, query, args...).Scan(
|
||||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||||
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &enabledInt, &pkg.Status,
|
&manifestJSON, &enabledInt, &pkg.Status,
|
||||||
&pkg.SchemaVersion, &pkgSettings,
|
&pkg.SchemaVersion, &pkgSettings,
|
||||||
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
|
&pkg.Source, &adoptableInt, &adoptedFrom,
|
||||||
|
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -288,8 +299,10 @@ func (s *PackageStore) scanOne(ctx context.Context, query string, args ...interf
|
|||||||
}
|
}
|
||||||
pkg.IsSystem = isSystemInt != 0
|
pkg.IsSystem = isSystemInt != 0
|
||||||
pkg.Enabled = enabledInt != 0
|
pkg.Enabled = enabledInt != 0
|
||||||
|
pkg.Adoptable = adoptableInt != 0
|
||||||
pkg.TeamID = NullableStringPtr(teamID)
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
|
||||||
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
||||||
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
return &pkg, nil
|
return &pkg, nil
|
||||||
@@ -305,25 +318,29 @@ func (s *PackageStore) scanMany(ctx context.Context, query string, args ...inter
|
|||||||
var result []store.PackageRegistration
|
var result []store.PackageRegistration
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var pkg store.PackageRegistration
|
var pkg store.PackageRegistration
|
||||||
var teamID, installedBy sql.NullString
|
var teamID, installedBy, adoptedFrom sql.NullString
|
||||||
var manifestJSON string
|
var manifestJSON string
|
||||||
var pkgSettings string
|
var pkgSettings string
|
||||||
var enabledInt int
|
var enabledInt int
|
||||||
var isSystemInt int
|
var isSystemInt int
|
||||||
|
var adoptableInt int
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
&pkg.ID, &pkg.Title, &pkg.Type, &pkg.Version, &pkg.Description,
|
||||||
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
&pkg.Author, &pkg.Tier, &isSystemInt, &pkg.Scope,
|
||||||
&teamID, &installedBy,
|
&teamID, &installedBy,
|
||||||
&manifestJSON, &enabledInt, &pkg.Status,
|
&manifestJSON, &enabledInt, &pkg.Status,
|
||||||
&pkg.SchemaVersion, &pkgSettings,
|
&pkg.SchemaVersion, &pkgSettings,
|
||||||
&pkg.Source, &pkg.InstalledAt, &pkg.UpdatedAt,
|
&pkg.Source, &adoptableInt, &adoptedFrom,
|
||||||
|
&pkg.InstalledAt, &pkg.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pkg.IsSystem = isSystemInt != 0
|
pkg.IsSystem = isSystemInt != 0
|
||||||
pkg.Enabled = enabledInt != 0
|
pkg.Enabled = enabledInt != 0
|
||||||
|
pkg.Adoptable = adoptableInt != 0
|
||||||
pkg.TeamID = NullableStringPtr(teamID)
|
pkg.TeamID = NullableStringPtr(teamID)
|
||||||
pkg.InstalledBy = NullableStringPtr(installedBy)
|
pkg.InstalledBy = NullableStringPtr(installedBy)
|
||||||
|
pkg.AdoptedFrom = NullableStringPtr(adoptedFrom)
|
||||||
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
json.Unmarshal([]byte(manifestJSON), &pkg.Manifest)
|
||||||
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
pkg.PackageSettings = json.RawMessage(pkgSettings)
|
||||||
result = append(result, pkg)
|
result = append(result, pkg)
|
||||||
@@ -418,6 +435,23 @@ func (s *PackageStore) DeleteTeamSettings(ctx context.Context, pkgID, teamID str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.9.4 — Package Adoption ───────────────
|
||||||
|
|
||||||
|
func (s *PackageStore) ListAdoptable(ctx context.Context) ([]store.PackageRegistration, error) {
|
||||||
|
return s.scanMany(ctx, `
|
||||||
|
SELECT `+pkgCols+`
|
||||||
|
FROM packages p
|
||||||
|
WHERE p.adoptable = 1 AND p.scope = 'global' AND p.enabled = 1
|
||||||
|
ORDER BY p.title`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PackageStore) GetByAdoptedFrom(ctx context.Context, sourceID, teamID string) (*store.PackageRegistration, error) {
|
||||||
|
return s.scanOne(ctx, `
|
||||||
|
SELECT `+pkgCols+`
|
||||||
|
FROM packages p
|
||||||
|
WHERE p.adopted_from = ? AND p.team_id = ?`, sourceID, teamID)
|
||||||
|
}
|
||||||
|
|
||||||
func nullStrPtr(s *string) sql.NullString {
|
func nullStrPtr(s *string) sql.NullString {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return sql.NullString{}
|
return sql.NullString{}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
|
|
||||||
"armature/models"
|
"armature/models"
|
||||||
"armature/store"
|
"armature/store"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TeamStore struct{}
|
type TeamStore struct{}
|
||||||
@@ -399,3 +401,40 @@ func (s *TeamStore) RemoveAllUserRoles(ctx context.Context, teamID, userID strin
|
|||||||
teamID, userID)
|
teamID, userID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── v0.9.4 — Team Role Catalog ──
|
||||||
|
|
||||||
|
func (s *TeamStore) AddRoleToCatalog(ctx context.Context, teamID, role, sourcePkgID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
INSERT INTO team_role_catalog (id, team_id, role, source_package_id)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT (team_id, role) DO NOTHING`,
|
||||||
|
uuid.New().String(), teamID, role, sourcePkgID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) ListRoleCatalog(ctx context.Context, teamID string) ([]store.TeamRoleCatalogEntry, error) {
|
||||||
|
rows, err := DB.QueryContext(ctx, `
|
||||||
|
SELECT role, COALESCE(source_package_id, '') FROM team_role_catalog
|
||||||
|
WHERE team_id = ? ORDER BY role`, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var result []store.TeamRoleCatalogEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e store.TeamRoleCatalogEntry
|
||||||
|
if err := rows.Scan(&e.Role, &e.SourcePackageID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, e)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TeamStore) RemoveRoleCatalogBySource(ctx context.Context, teamID, sourcePkgID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx, `
|
||||||
|
DELETE FROM team_role_catalog WHERE team_id = ? AND source_package_id = ?`,
|
||||||
|
teamID, sourcePkgID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user