feat(ui): add identity management for tenants and users
CI / build-and-test (push) Successful in 1m47s
CI / build-and-test (push) Successful in 1m47s
* Implement tenant and user creation in IdentityPage * Add API calls for managing tenants and users * Introduce tenant-scoped API requests * Update sidebar to include identity navigation * Create BooleanStatusBadge component for key status
This commit is contained in:
@@ -23,6 +23,8 @@ auth:
|
||||
keys:
|
||||
- id: "local-client"
|
||||
value: "replace-me"
|
||||
tenant_id: "local"
|
||||
superadmin: true
|
||||
description: "main local client key"
|
||||
oauth:
|
||||
clients:
|
||||
|
||||
@@ -21,6 +21,8 @@ auth:
|
||||
keys:
|
||||
- id: "local-client"
|
||||
value: "replace-me"
|
||||
tenant_id: "local"
|
||||
superadmin: true
|
||||
description: "main local client key"
|
||||
oauth:
|
||||
clients:
|
||||
|
||||
@@ -21,6 +21,8 @@ auth:
|
||||
keys:
|
||||
- id: "local-client"
|
||||
value: "replace-me"
|
||||
tenant_id: "local"
|
||||
superadmin: true
|
||||
description: "main local client key"
|
||||
oauth:
|
||||
clients:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Tenant identity management
|
||||
|
||||
The previous tenancy implementation used every API-key ID as its tenant key. This meant that two keys could not share data and old unscoped rows could not be reached by any key after tenancy was enabled.
|
||||
|
||||
Implemented a separate tenant mapping: configured and managed API keys now resolve to an assigned tenant ID, while unassigned configured keys retain their historical key-ID tenant boundary for compatibility. Added tenant, tenant-user, key-assignment, and managed-secret records, with a one-time, explicit legacy adoption endpoint that assigns only `NULL` tenant-key rows to a selected tenant.
|
||||
|
||||
The admin UI now has an Identity page for tenants, users, configured-key assignment, managed-key creation (secret shown once), disabling keys, and legacy adoption. Managed secrets are stored only as SHA-256 hashes. The AMCS MCP capture-thought tool was unavailable in this session, so this local log is the required fallback summary.
|
||||
|
||||
Configured keys also accept an optional `auth.keys[].tenant_id`. This supplies the initial tenant boundary at startup; an explicit assignment saved through the Identity UI overrides it.
|
||||
|
||||
Tenant-owned root records now reference `tenants(id)` through `tenant_id`; tenant scoped resources include skills, guardrails, personas, parts, traits, and character arcs. Tenant-selection in the UI is sent as `X-AMCS-Tenant-ID` for admin/ResolveSpec requests.
|
||||
|
||||
With explicit approval that tenancy data is disposable, the compatibility migration now renames the ownership column from `tenant_key` to `tenant_id` across the historical migration chain.
|
||||
|
||||
Tenant selection now centrally filters every tenant-owned ResolveSpec read, update, and delete in the admin UI, and injects the selected `tenant_id` on create requests.
|
||||
|
||||
Identity mutations now require a configured API key with `superadmin: true`; tenants themselves remain database-managed.
|
||||
|
||||
Fixed the admin sidebar tenant-scope selector flicker. Its focus handler reloaded the tenant list and disabled the native select while its picker was opening; tenants now load on sidebar mount only, so a selection remains open and usable. Validated with `pnpm check` (0 errors, 0 warnings).
|
||||
@@ -0,0 +1,3 @@
|
||||
# ResolveSpec array scanning
|
||||
|
||||
Diagnosed `/api/rs/public/agent_skills` failing to scan `tags text[]`. The generated Bun model correctly uses `sqltypes.SqlStringArray`, which implements `sql.Scanner`, but ResolveSpec's read path supplied a separate scan destination instead of scanning the query's registered Bun model. Updated the vendored ResolveSpec handler to call `ScanModel`, and for single-row reads to set the single record as the query model first. This preserves Bun's model field scanner for PostgreSQL arrays.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Per-user tenancy progress review
|
||||
|
||||
Reviewed `docs/per-user-tenancy-plan.md` against the staged worktree. Identity, tenant mappings, schema/model migrations, and primary project/thought/file/catalog scoping are present. The plan's named hardening targets—learnings, plans, chat histories, thought-learning links, and project persona joins—still have no tenant helper predicates. Only auth/keyring tests are staged; the plan's cross-tenant store/tool and migration test matrix has not yet been added or run.
|
||||
@@ -7,9 +7,9 @@ Gitea issue #10 asks AMCS to isolate memories by user, tenant, or workspace. The
|
||||
Observed code paths:
|
||||
|
||||
- Authentication enters through `internal/auth/middleware.go`. API-key auth uses the configured header, defaulting to `x-brain-key`; bearer tokens can resolve through OAuth `TokenStore` or API keyring; HTTP Basic resolves OAuth client credentials.
|
||||
- `internal/auth/middleware.go` writes both `auth.key_id` and `tenancy.tenant_key` into the request context. The tenant key is intentionally opaque and currently equals the authenticated key id or OAuth client id.
|
||||
- `internal/auth/middleware.go` writes both `auth.key_id` and `tenancy.tenant_id` into the request context. The tenant key is intentionally opaque and currently equals the authenticated key id or OAuth client id.
|
||||
- `internal/tenancy/tenancy.go` exposes `WithTenantKey` and `KeyFromContext` only; callers should not infer user semantics from the tenant string.
|
||||
- Schema already has `tenant_key` on `projects`, `thoughts`, `stored_files`, `learnings`, `plans`, and `chat_histories` in `schema/*.dbml`.
|
||||
- Schema already has `tenant_id` on `projects`, `thoughts`, `stored_files`, `learnings`, `plans`, and `chat_histories` in `schema/*.dbml`.
|
||||
- `internal/store/tenancy.go` provides helper functions for appending tenant predicates to SQL.
|
||||
- Tenant scoping is already present in project, thought, and stored-file store paths. Some other project-owned domains still need explicit tenant enforcement.
|
||||
|
||||
@@ -17,9 +17,9 @@ Observed code paths:
|
||||
|
||||
Use the authenticated principal id as the tenant boundary:
|
||||
|
||||
1. API key: resolve token through `auth.Keyring.Lookup`; use returned key id as `tenant_key`.
|
||||
2. OAuth bearer token: resolve token through `TokenStore.Lookup`; use returned client id/key id as `tenant_key`.
|
||||
3. OAuth Basic client credentials: resolve through `OAuthRegistry.Lookup`; use returned client id as `tenant_key`.
|
||||
1. API key: resolve token through `auth.Keyring.Lookup`; use returned key id as `tenant_id`.
|
||||
2. OAuth bearer token: resolve token through `TokenStore.Lookup`; use returned client id/key id as `tenant_id`.
|
||||
3. OAuth Basic client credentials: resolve through `OAuthRegistry.Lookup`; use returned client id as `tenant_id`.
|
||||
4. Unauthenticated requests must not get tenant context and must not reach protected MCP/API handlers.
|
||||
|
||||
Do not store raw API keys or bearer tokens in tenant columns. Store only stable configured key ids/client ids. Yes, it is less flashy than inventing an account service before breakfast, but it keeps the trust boundary small and auditable.
|
||||
@@ -28,7 +28,7 @@ Do not store raw API keys or bearer tokens in tenant columns. Store only stable
|
||||
|
||||
Source-of-truth DBML changes:
|
||||
|
||||
- Add nullable `tenant_key text` to all user-owned tables:
|
||||
- Add nullable `tenant_id text` to all user-owned tables:
|
||||
- `projects`
|
||||
- `thoughts`
|
||||
- `stored_files`
|
||||
@@ -36,9 +36,9 @@ Source-of-truth DBML changes:
|
||||
- `plans`
|
||||
- `chat_histories`
|
||||
- Add tenant indexes:
|
||||
- single-column `tenant_key` for direct filtering
|
||||
- `(tenant_key, name)` unique on `projects`, replacing global `projects.name` uniqueness
|
||||
- `(tenant_key, project_id)` on `thoughts` for common project-scoped memory lookups
|
||||
- single-column `tenant_id` for direct filtering
|
||||
- `(tenant_id, name)` unique on `projects`, replacing global `projects.name` uniqueness
|
||||
- `(tenant_id, project_id)` on `thoughts` for common project-scoped memory lookups
|
||||
- Regenerate SQL migrations and generated models from DBML; do not hand-edit generated Go models except as a temporary debugging step.
|
||||
|
||||
Important follow-up: `agent_skills`, `agent_guardrails`, `agent_personas`, `agent_parts`, traits, and arcs are currently global catalogs. Keep them global unless product requirements say skills/personas are private per tenant. Project join tables inherit protection through tenant-scoped project ids, but direct join queries must verify the project belongs to the tenant.
|
||||
@@ -49,9 +49,9 @@ All request-facing store methods that touch tenant-owned rows must include tenan
|
||||
|
||||
Rules:
|
||||
|
||||
- Inserts must populate `tenant_key` from context.
|
||||
- Gets/updates/deletes by id or guid must add `and tenant_key = $n`.
|
||||
- Lists/searches must add `tenant_key = $n` before other filters.
|
||||
- Inserts must populate `tenant_id` from context.
|
||||
- Gets/updates/deletes by id or guid must add `and tenant_id = $n`.
|
||||
- Lists/searches must add `tenant_id = $n` before other filters.
|
||||
- Joins must scope the tenant-owned root table. Example: project summaries should count only thoughts that belong to the same tenant as the project, not merely thoughts with the same `project_id`.
|
||||
- Background maintenance jobs must either run per tenant, carry tenant context explicitly, or intentionally operate cross-tenant with an internal-only code path documented in the job.
|
||||
|
||||
@@ -74,24 +74,24 @@ Paths needing audit/hardening:
|
||||
|
||||
Migration approach for existing single-tenant installs:
|
||||
|
||||
1. Add nullable `tenant_key` columns first; do not make them `not null` initially because existing deployments have historical rows without a principal.
|
||||
2. Backfill existing rows to a configured default tenant only if the instance enables multi-tenant mode or defines `auth.default_tenant_key`. Otherwise leave `NULL` rows visible only to unauthenticated/internal single-tenant contexts.
|
||||
3. Replace global project-name uniqueness with `(tenant_key, name)` uniqueness. For PostgreSQL, preserve legacy `NULL` semantics carefully: multiple null-tenant projects with the same name may be possible unless a partial unique index is added for null tenant rows.
|
||||
1. Add nullable `tenant_id` columns first; do not make them `not null` initially because existing deployments have historical rows without a principal.
|
||||
2. Backfill existing rows to a configured default tenant only if the instance enables multi-tenant mode or defines `auth.default_tenant_id`. Otherwise leave `NULL` rows visible only to unauthenticated/internal single-tenant contexts.
|
||||
3. Replace global project-name uniqueness with `(tenant_id, name)` uniqueness. For PostgreSQL, preserve legacy `NULL` semantics carefully: multiple null-tenant projects with the same name may be possible unless a partial unique index is added for null tenant rows.
|
||||
4. Add indexes concurrently where practical for production-sized tables.
|
||||
5. Document that after enabling auth-backed tenancy, legacy null-tenant data is not visible to authenticated tenants unless backfilled.
|
||||
|
||||
Recommended config addition:
|
||||
|
||||
- `auth.default_tenant_key` or `tenancy.default_key` for one-time/self-hosted backfill and development.
|
||||
- `auth.default_tenant_id` or `tenancy.default_key` for one-time/self-hosted backfill and development.
|
||||
- Optional `tenancy.mode: single|authenticated` so operators can keep current single-user behavior deliberately instead of discovering isolation by accident. An accident in auth is just a breach with better branding.
|
||||
|
||||
## Authorization checks
|
||||
|
||||
Authorization is row ownership by tenant key:
|
||||
|
||||
- A request may only see or mutate rows whose `tenant_key` equals the authenticated tenant key.
|
||||
- A request may only see or mutate rows whose `tenant_id` equals the authenticated tenant key.
|
||||
- Cross-tenant ids/gids should behave as not found, not forbidden, to avoid existence leaks.
|
||||
- Tenant key is server-derived only. Ignore any client-provided `tenant_key` fields on tool/API inputs.
|
||||
- Tenant key is server-derived only. Ignore any client-provided `tenant_id` fields on tool/API inputs.
|
||||
- Project id references in create/update operations must be validated against the same tenant before use. This prevents attaching a new thought/file/plan to another tenant's project id.
|
||||
- Relationship operations must verify both endpoints are in the same tenant before creating links.
|
||||
- Global catalogs can be read across tenants only if intentionally shared; project-specific associations must be tenant-checked through the project.
|
||||
@@ -117,7 +117,7 @@ HTTP/API boundaries:
|
||||
UI:
|
||||
|
||||
- Project selector/list should naturally show tenant-scoped projects.
|
||||
- Admin tables for projects/thoughts/files/learnings/plans/chat histories must not show `tenant_key` as an editable field.
|
||||
- Admin tables for projects/thoughts/files/learnings/plans/chat histories must not show `tenant_id` as an editable field.
|
||||
- If a tenant switcher is ever added, it must map to a server-side authenticated principal or admin impersonation path, not a client-side query parameter. Obviously.
|
||||
|
||||
## Test cases
|
||||
@@ -143,7 +143,7 @@ Minimum test matrix:
|
||||
Assumptions:
|
||||
|
||||
- The first tenancy boundary is authenticated principal id, not human account, org, or workspace. This is consistent with the current auth system and avoids adding an account model prematurely.
|
||||
- Null `tenant_key` remains the compatibility path for existing single-tenant/internal flows.
|
||||
- Null `tenant_id` remains the compatibility path for existing single-tenant/internal flows.
|
||||
- Global skills/personas/guardrails remain shared catalogs until a separate product decision makes them tenant-private.
|
||||
|
||||
Blockers/decisions needed:
|
||||
|
||||
@@ -4,7 +4,7 @@ go 1.26.1
|
||||
|
||||
require (
|
||||
git.warky.dev/wdevs/relspecgo v1.0.62
|
||||
github.com/bitechdev/ResolveSpec v1.1.26
|
||||
github.com/bitechdev/ResolveSpec v1.1.27
|
||||
github.com/google/jsonschema-go v0.4.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
|
||||
@@ -36,8 +36,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bitechdev/ResolveSpec v1.1.26 h1:/OYc1Mjcfm4Qxq8Xy5UY/32l5cSqkNe8ieeOqIEbK5o=
|
||||
github.com/bitechdev/ResolveSpec v1.1.26/go.mod h1:GF51sMRCWbAyri2WNae3IZAFM/2s6DG6i3eTTrobbVs=
|
||||
github.com/bitechdev/ResolveSpec v1.1.27 h1:qugBwR3Qoy4tNKhAyJsFszZE+kRR0gzl5w42XE3/scY=
|
||||
github.com/bitechdev/ResolveSpec v1.1.27/go.mod h1:GF51sMRCWbAyri2WNae3IZAFM/2s6DG6i3eTTrobbVs=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/auth"
|
||||
)
|
||||
|
||||
type identityAdmin struct {
|
||||
pool *pgxpool.Pool
|
||||
keyring *auth.Keyring
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func newIdentityAdmin(pool *pgxpool.Pool, keyring *auth.Keyring, logger *slog.Logger) *identityAdmin {
|
||||
return &identityAdmin{pool: pool, keyring: keyring, logger: logger}
|
||||
}
|
||||
|
||||
func loadIdentityKeyring(ctx context.Context, pool *pgxpool.Pool, keyring *auth.Keyring) error {
|
||||
if keyring == nil {
|
||||
return nil
|
||||
}
|
||||
rows, err := pool.Query(ctx, `select a.key_id, a.tenant_id, a.enabled, coalesce(m.secret_hash, '') from api_key_assignments a left join managed_api_keys m on m.key_id = a.key_id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var keyID, tenantID, hash string
|
||||
var enabled bool
|
||||
if err := rows.Scan(&keyID, &tenantID, &enabled, &hash); err != nil {
|
||||
return err
|
||||
}
|
||||
keyring.AssignTenant(keyID, tenantID)
|
||||
if hash != "" {
|
||||
keyring.AddManaged(keyID, hash, enabled)
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// ensureConfiguredTenants makes a tenant referenced in static YAML visible to
|
||||
// the admin UI as well as to the authentication middleware.
|
||||
func ensureConfiguredTenants(ctx context.Context, pool *pgxpool.Pool, keyring *auth.Keyring) error {
|
||||
if keyring == nil {
|
||||
return nil
|
||||
}
|
||||
for _, key := range keyring.ConfiguredKeys() {
|
||||
tenantID := strings.TrimSpace(key.TenantID)
|
||||
if tenantID == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `insert into tenants (id, name) values ($1, $1) on conflict (id) do nothing`, tenantID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *identityAdmin) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
keyID, ok := auth.KeyIDFromContext(r.Context())
|
||||
if !ok || a.keyring == nil || !a.keyring.IsSuperadmin(keyID) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "superadmin API key required"})
|
||||
return
|
||||
}
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/admin/identity")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && path == "":
|
||||
a.list(w, r)
|
||||
case r.Method == http.MethodPost && path == "/tenants":
|
||||
a.createTenant(w, r)
|
||||
case r.Method == http.MethodPost && path == "/users":
|
||||
a.createUser(w, r)
|
||||
case r.Method == http.MethodPost && path == "/keys":
|
||||
a.createKey(w, r)
|
||||
case r.Method == http.MethodPatch && strings.HasPrefix(path, "/keys/"):
|
||||
a.updateKey(w, r, strings.TrimPrefix(path, "/keys/"))
|
||||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/tenants/") && strings.HasSuffix(path, "/adopt-legacy"):
|
||||
a.adoptLegacy(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/tenants/"), "/adopt-legacy"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type tenantDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
type userDTO struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
type keyDTO struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Source string `json:"source"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (a *identityAdmin) list(w http.ResponseWriter, r *http.Request) {
|
||||
result := struct {
|
||||
Tenants []tenantDTO `json:"tenants"`
|
||||
Users []userDTO `json:"users"`
|
||||
Keys []keyDTO `json:"keys"`
|
||||
}{Tenants: []tenantDTO{}, Users: []userDTO{}, Keys: []keyDTO{}}
|
||||
rows, err := a.pool.Query(r.Context(), `select id, name, created_at from tenants order by name`)
|
||||
if err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var x tenantDTO
|
||||
if err := rows.Scan(&x.ID, &x.Name, &x.CreatedAt); err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
result.Tenants = append(result.Tenants, x)
|
||||
}
|
||||
rows, err = a.pool.Query(r.Context(), `select id, tenant_id, name, email, created_at from tenant_users order by name`)
|
||||
if err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var x userDTO
|
||||
if err := rows.Scan(&x.ID, &x.TenantID, &x.Name, &x.Email, &x.CreatedAt); err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
result.Users = append(result.Users, x)
|
||||
}
|
||||
configured := make(map[string]authKey)
|
||||
if a.keyring != nil {
|
||||
for _, key := range a.keyring.ConfiguredKeys() {
|
||||
configured[key.ID] = authKey{description: key.Description}
|
||||
}
|
||||
}
|
||||
rows, err = a.pool.Query(r.Context(), `select key_id, tenant_id, user_id, description, source, enabled, created_at from api_key_assignments order by key_id`)
|
||||
if err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var x keyDTO
|
||||
if err := rows.Scan(&x.ID, &x.TenantID, &x.UserID, &x.Description, &x.Source, &x.Enabled, &x.CreatedAt); err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
result.Keys = append(result.Keys, x)
|
||||
delete(configured, x.ID)
|
||||
}
|
||||
for id, key := range configured {
|
||||
result.Keys = append(result.Keys, keyDTO{ID: id, Description: key.description, Source: "configured", Enabled: true})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
type authKey struct{ description string }
|
||||
|
||||
func (a *identityAdmin) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
body.Name = strings.TrimSpace(body.Name)
|
||||
if body.Name == "" {
|
||||
badRequest(w, "name is required")
|
||||
return
|
||||
}
|
||||
x := tenantDTO{ID: newIdentityID(), Name: body.Name}
|
||||
err := a.pool.QueryRow(r.Context(), `insert into tenants (id,name) values ($1,$2) returning created_at`, x.ID, x.Name).Scan(&x.CreatedAt)
|
||||
if err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, x)
|
||||
}
|
||||
func (a *identityAdmin) createUser(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
TenantID, Name string
|
||||
Email *string `json:"email"`
|
||||
}
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
body.TenantID = strings.TrimSpace(body.TenantID)
|
||||
body.Name = strings.TrimSpace(body.Name)
|
||||
if body.TenantID == "" || body.Name == "" {
|
||||
badRequest(w, "tenant_id and name are required")
|
||||
return
|
||||
}
|
||||
x := userDTO{ID: newIdentityID(), TenantID: body.TenantID, Name: body.Name, Email: body.Email}
|
||||
err := a.pool.QueryRow(r.Context(), `insert into tenant_users (id,tenant_id,name,email) values ($1,$2,$3,$4) returning created_at`, x.ID, x.TenantID, x.Name, x.Email).Scan(&x.CreatedAt)
|
||||
if err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, x)
|
||||
}
|
||||
func (a *identityAdmin) createKey(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
UserID *string `json:"user_id"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
body.TenantID = strings.TrimSpace(body.TenantID)
|
||||
if body.TenantID == "" {
|
||||
badRequest(w, "tenant_id is required")
|
||||
return
|
||||
}
|
||||
secret, hash, err := auth.GenerateSecret()
|
||||
if err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
x := keyDTO{ID: newIdentityID(), TenantID: body.TenantID, UserID: body.UserID, Description: strings.TrimSpace(body.Description), Source: "managed", Enabled: true}
|
||||
tx, err := a.pool.Begin(r.Context())
|
||||
if err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
if err = tx.QueryRow(r.Context(), `insert into api_key_assignments (key_id,tenant_id,user_id,description,source,enabled) values ($1,$2,$3,$4,'managed',true) returning created_at`, x.ID, x.TenantID, x.UserID, x.Description).Scan(&x.CreatedAt); err == nil {
|
||||
_, err = tx.Exec(r.Context(), `insert into managed_api_keys (key_id,secret_hash) values ($1,$2)`, x.ID, hash)
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.Commit(r.Context())
|
||||
}
|
||||
if err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
a.keyring.AddManaged(x.ID, hash, true)
|
||||
a.keyring.AssignTenant(x.ID, x.TenantID)
|
||||
writeJSON(w, http.StatusCreated, struct {
|
||||
Key keyDTO `json:"key"`
|
||||
Secret string `json:"secret"`
|
||||
}{x, secret})
|
||||
}
|
||||
func (a *identityAdmin) updateKey(w http.ResponseWriter, r *http.Request, keyID string) {
|
||||
var body struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
UserID *string `json:"user_id"`
|
||||
Description *string `json:"description"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if !decodeJSON(w, r, &body) {
|
||||
return
|
||||
}
|
||||
body.TenantID = strings.TrimSpace(body.TenantID)
|
||||
if body.TenantID == "" {
|
||||
badRequest(w, "tenant_id is required")
|
||||
return
|
||||
}
|
||||
if !a.keyring.IsConfigured(keyID) {
|
||||
var exists bool
|
||||
if err := a.pool.QueryRow(r.Context(), `select exists(select 1 from managed_api_keys where key_id=$1)`, keyID).Scan(&exists); err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
badRequest(w, "unknown key id")
|
||||
return
|
||||
}
|
||||
}
|
||||
var x keyDTO
|
||||
err := a.pool.QueryRow(r.Context(), `insert into api_key_assignments (key_id,tenant_id,user_id,description,source,enabled) values ($1,$2,$3,coalesce($4,''),case when $6 then 'configured' else 'managed' end,coalesce($5,true)) on conflict (key_id) do update set tenant_id=excluded.tenant_id,user_id=excluded.user_id,description=coalesce($4,api_key_assignments.description),enabled=coalesce($5,api_key_assignments.enabled) returning key_id,tenant_id,user_id,description,source,enabled,created_at`, keyID, body.TenantID, body.UserID, body.Description, body.Enabled, a.keyring.IsConfigured(keyID)).Scan(&x.ID, &x.TenantID, &x.UserID, &x.Description, &x.Source, &x.Enabled, &x.CreatedAt)
|
||||
if err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
a.keyring.AssignTenant(x.ID, x.TenantID)
|
||||
a.keyring.SetManagedEnabled(x.ID, x.Enabled)
|
||||
writeJSON(w, http.StatusOK, x)
|
||||
}
|
||||
func (a *identityAdmin) adoptLegacy(w http.ResponseWriter, r *http.Request, tenantID string) {
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
if tenantID == "" {
|
||||
badRequest(w, "tenant id is required")
|
||||
return
|
||||
}
|
||||
tables := []string{"projects", "thoughts", "stored_files", "learnings", "plans", "chat_histories"}
|
||||
tx, err := a.pool.Begin(r.Context())
|
||||
if err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
var exists bool
|
||||
if err = tx.QueryRow(r.Context(), `select exists(select 1 from tenants where id=$1)`, tenantID).Scan(&exists); err == nil && !exists {
|
||||
badRequest(w, "tenant does not exist")
|
||||
return
|
||||
}
|
||||
for _, table := range tables {
|
||||
if _, err = tx.Exec(r.Context(), fmt.Sprintf("update %s set tenant_id=$1 where tenant_id is null", table), tenantID); err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(r.Context()); err != nil {
|
||||
identityError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
func newIdentityID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
defer r.Body.Close()
|
||||
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||
badRequest(w, "invalid JSON")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
func badRequest(w http.ResponseWriter, message string) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": message})
|
||||
}
|
||||
func identityError(w http.ResponseWriter, err error) {
|
||||
if a, ok := err.(interface{ SQLState() string }); ok && a.SQLState() == "23505" {
|
||||
badRequest(w, "that value already exists")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "identity operation failed"})
|
||||
}
|
||||
@@ -91,6 +91,14 @@ func Run(ctx context.Context, configPath string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
keyring = auth.NewManagedKeyring()
|
||||
}
|
||||
if err := ensureConfiguredTenants(ctx, db.Pool(), keyring); err != nil {
|
||||
return fmt.Errorf("create configured tenants: %w", err)
|
||||
}
|
||||
if err := loadIdentityKeyring(ctx, db.Pool(), keyring); err != nil {
|
||||
return fmt.Errorf("load identity key assignments: %w", err)
|
||||
}
|
||||
tokenStore = auth.NewTokenStore(0)
|
||||
if len(cfg.Auth.OAuth.Clients) > 0 {
|
||||
@@ -192,6 +200,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
||||
enrichmentRetryer := tools.NewEnrichmentRetryer(context.Background(), db, bgMetadata, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
|
||||
backfillTool := tools.NewBackfillTool(db, bgEmbeddings, activeProjects, logger)
|
||||
adminActions := newAdminActions(backfillTool, enrichmentRetryer, logger)
|
||||
identityAdmin := newIdentityAdmin(db.Pool(), keyring, logger)
|
||||
|
||||
toolSet := mcpserver.ToolSet{
|
||||
Capture: tools.NewCaptureTool(db, embeddings, cfg.Capture, activeProjects, enrichmentRetryer, backfillTool),
|
||||
@@ -246,6 +255,8 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
||||
mux.HandleFunc("/api/oauth/token", oauthTokenHandler(oauthRegistry, tokenStore, authCodes, logger))
|
||||
mux.Handle("/api/admin/actions/backfill", authMiddleware(adminActions.backfillHandler()))
|
||||
mux.Handle("/api/admin/actions/retry-metadata", authMiddleware(adminActions.retryMetadataHandler()))
|
||||
mux.Handle("/api/admin/identity", authMiddleware(identityAdmin.handler()))
|
||||
mux.Handle("/api/admin/identity/", authMiddleware(identityAdmin.handler()))
|
||||
mux.HandleFunc("/favicon.ico", serveFavicon)
|
||||
mux.HandleFunc("/images/project.jpg", serveHomeImage)
|
||||
mux.HandleFunc("/images/icon.png", serveIcon)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/uptrace/bunrouter"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/store"
|
||||
"git.warky.dev/wdevs/amcs/internal/tenancy"
|
||||
)
|
||||
|
||||
func registerResolveSpecAdminRoutes(mux *http.ServeMux, db *store.DB, middleware func(http.Handler) http.Handler, logger *slog.Logger) error {
|
||||
@@ -45,7 +46,12 @@ func registerResolveSpecAdminRoutes(mux *http.ServeMux, db *store.DB, middleware
|
||||
rsMount.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
middleware(rsMount).ServeHTTP(w, r)
|
||||
middleware(http.HandlerFunc(func(w http.ResponseWriter, authenticated *http.Request) {
|
||||
if tenantID := strings.TrimSpace(authenticated.Header.Get("X-AMCS-Tenant-ID")); tenantID != "" {
|
||||
authenticated = authenticated.WithContext(tenancy.WithTenantKey(authenticated.Context(), tenantID))
|
||||
}
|
||||
rsMount.ServeHTTP(w, authenticated)
|
||||
})).ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
mux.Handle("/api/rs/", protectedRSMount)
|
||||
|
||||
@@ -14,12 +14,14 @@ func resolveSpecModels() []resolveSpecModel {
|
||||
{schema: "public", entity: "agent_personas", model: generatedmodels.ModelPublicAgentPersonas{}},
|
||||
{schema: "public", entity: "agent_skills", model: generatedmodels.ModelPublicAgentSkills{}},
|
||||
{schema: "public", entity: "agent_traits", model: generatedmodels.ModelPublicAgentTraits{}},
|
||||
{schema: "public", entity: "api_key_assignments", model: generatedmodels.ModelPublicAPIKeyAssignments{}},
|
||||
{schema: "public", entity: "arc_stage_parts", model: generatedmodels.ModelPublicArcStageParts{}},
|
||||
{schema: "public", entity: "arc_stages", model: generatedmodels.ModelPublicArcStages{}},
|
||||
{schema: "public", entity: "character_arcs", model: generatedmodels.ModelPublicCharacterArcs{}},
|
||||
{schema: "public", entity: "chat_histories", model: generatedmodels.ModelPublicChatHistories{}},
|
||||
{schema: "public", entity: "embeddings", model: generatedmodels.ModelPublicEmbeddings{}},
|
||||
{schema: "public", entity: "learnings", model: generatedmodels.ModelPublicLearnings{}},
|
||||
{schema: "public", entity: "managed_api_keys", model: generatedmodels.ModelPublicManagedAPIKeys{}},
|
||||
{schema: "public", entity: "oauth_clients", model: generatedmodels.ModelPublicOauthClients{}},
|
||||
{schema: "public", entity: "persona_arc", model: generatedmodels.ModelPublicPersonaArc{}},
|
||||
{schema: "public", entity: "plan_dependencies", model: generatedmodels.ModelPublicPlanDependencies{}},
|
||||
@@ -32,6 +34,8 @@ func resolveSpecModels() []resolveSpecModel {
|
||||
{schema: "public", entity: "project_skills", model: generatedmodels.ModelPublicProjectSkills{}},
|
||||
{schema: "public", entity: "projects", model: generatedmodels.ModelPublicProjects{}},
|
||||
{schema: "public", entity: "stored_files", model: generatedmodels.ModelPublicStoredFiles{}},
|
||||
{schema: "public", entity: "tenant_users", model: generatedmodels.ModelPublicTenantUsers{}},
|
||||
{schema: "public", entity: "tenants", model: generatedmodels.ModelPublicTenants{}},
|
||||
{schema: "public", entity: "thought_learning_links", model: generatedmodels.ModelPublicThoughtLearningLinks{}},
|
||||
{schema: "public", entity: "thought_links", model: generatedmodels.ModelPublicThoughtLinks{}},
|
||||
{schema: "public", entity: "thoughts", model: generatedmodels.ModelPublicThoughts{}},
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
|
||||
var (
|
||||
//go:embed ui/dist
|
||||
uiFiles embed.FS
|
||||
uiDistFS fs.FS
|
||||
uiFiles embed.FS
|
||||
uiDistFS fs.FS
|
||||
indexHTML []byte
|
||||
)
|
||||
|
||||
|
||||
+113
-2
@@ -1,14 +1,27 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/config"
|
||||
)
|
||||
|
||||
type Keyring struct {
|
||||
keys []config.APIKey
|
||||
mu sync.RWMutex
|
||||
keys []config.APIKey
|
||||
tenantsByKeyID map[string]string
|
||||
managed map[string]managedKey
|
||||
}
|
||||
|
||||
type managedKey struct {
|
||||
secretHash string
|
||||
enabled bool
|
||||
}
|
||||
|
||||
func NewKeyring(keys []config.APIKey) (*Keyring, error) {
|
||||
@@ -16,14 +29,112 @@ func NewKeyring(keys []config.APIKey) (*Keyring, error) {
|
||||
return nil, fmt.Errorf("keyring requires at least one key")
|
||||
}
|
||||
|
||||
return &Keyring{keys: append([]config.APIKey(nil), keys...)}, nil
|
||||
tenantsByKeyID := make(map[string]string)
|
||||
for _, key := range keys {
|
||||
if tenantID := strings.TrimSpace(key.TenantID); tenantID != "" {
|
||||
tenantsByKeyID[key.ID] = tenantID
|
||||
}
|
||||
}
|
||||
return &Keyring{keys: append([]config.APIKey(nil), keys...), tenantsByKeyID: tenantsByKeyID, managed: make(map[string]managedKey)}, nil
|
||||
}
|
||||
|
||||
// NewManagedKeyring is used when API credentials are administered in the
|
||||
// database rather than supplied through static configuration.
|
||||
func NewManagedKeyring() *Keyring {
|
||||
return &Keyring{tenantsByKeyID: make(map[string]string), managed: make(map[string]managedKey)}
|
||||
}
|
||||
|
||||
func (k *Keyring) Lookup(value string) (string, bool) {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
for _, key := range k.keys {
|
||||
if subtle.ConstantTimeCompare([]byte(key.Value), []byte(value)) == 1 {
|
||||
return key.ID, true
|
||||
}
|
||||
}
|
||||
hash := secretHash(value)
|
||||
for keyID, key := range k.managed {
|
||||
if key.enabled && subtle.ConstantTimeCompare([]byte(key.secretHash), []byte(hash)) == 1 {
|
||||
return keyID, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// TenantForKey returns the tenant boundary assigned to keyID. Unassigned
|
||||
// configured keys retain the historical key-ID boundary for compatibility.
|
||||
func (k *Keyring) TenantForKey(keyID string) string {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
if tenantID := k.tenantsByKeyID[keyID]; tenantID != "" {
|
||||
return tenantID
|
||||
}
|
||||
return keyID
|
||||
}
|
||||
|
||||
func (k *Keyring) AssignTenant(keyID, tenantID string) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if tenantID == "" {
|
||||
delete(k.tenantsByKeyID, keyID)
|
||||
return
|
||||
}
|
||||
k.tenantsByKeyID[keyID] = tenantID
|
||||
}
|
||||
|
||||
func (k *Keyring) AddManaged(keyID, secretHash string, enabled bool) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
k.managed[keyID] = managedKey{secretHash: secretHash, enabled: enabled}
|
||||
}
|
||||
|
||||
func (k *Keyring) ConfiguredKeys() []config.APIKey {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
return append([]config.APIKey(nil), k.keys...)
|
||||
}
|
||||
|
||||
func (k *Keyring) IsConfigured(keyID string) bool {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
for _, key := range k.keys {
|
||||
if key.ID == keyID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (k *Keyring) IsSuperadmin(keyID string) bool {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
for _, key := range k.keys {
|
||||
if key.ID == keyID {
|
||||
return key.Superadmin
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (k *Keyring) SetManagedEnabled(keyID string, enabled bool) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if key, ok := k.managed[keyID]; ok {
|
||||
key.enabled = enabled
|
||||
k.managed[keyID] = key
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateSecret() (string, string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
secret := "amcs_" + hex.EncodeToString(b)
|
||||
return secret, secretHash(secret), nil
|
||||
}
|
||||
|
||||
func secretHash(secret string) string {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
@@ -36,6 +36,26 @@ func TestNewKeyringAndLookup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredKeyUsesTenantID(t *testing.T) {
|
||||
keyring, err := NewKeyring([]config.APIKey{{ID: "agent-key", Value: "secret", TenantID: "acme"}})
|
||||
if err != nil {
|
||||
t.Fatalf("NewKeyring() error = %v", err)
|
||||
}
|
||||
if got := keyring.TenantForKey("agent-key"); got != "acme" {
|
||||
t.Fatalf("TenantForKey() = %q, want acme", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredKeySuperadmin(t *testing.T) {
|
||||
keyring, err := NewKeyring([]config.APIKey{{ID: "operator", Value: "secret", Superadmin: true}})
|
||||
if err != nil {
|
||||
t.Fatalf("NewKeyring() error = %v", err)
|
||||
}
|
||||
if !keyring.IsSuperadmin("operator") || keyring.IsSuperadmin("missing") {
|
||||
t.Fatal("IsSuperadmin() did not return the configured role")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareAllowsHeaderAuthAndSetsContext(t *testing.T) {
|
||||
keyring, err := NewKeyring([]config.APIKey{{ID: "client-a", Value: "secret"}})
|
||||
if err != nil {
|
||||
|
||||
@@ -53,7 +53,11 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
||||
}
|
||||
withTenant := func(ctx context.Context, keyID string) context.Context {
|
||||
ctx = context.WithValue(ctx, keyIDContextKey, keyID)
|
||||
return tenancy.WithTenantKey(ctx, keyID)
|
||||
tenantID := keyID
|
||||
if keyring != nil {
|
||||
tenantID = keyring.TenantForKey(keyID)
|
||||
}
|
||||
return tenancy.WithTenantKey(ctx, tenantID)
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -53,6 +53,8 @@ type AuthConfig struct {
|
||||
type APIKey struct {
|
||||
ID string `yaml:"id"`
|
||||
Value string `yaml:"value"`
|
||||
TenantID string `yaml:"tenant_id"`
|
||||
Superadmin bool `yaml:"superadmin"`
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ type ModelPublicAgentGuardrails struct {
|
||||
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||
Severity sql_types.SqlString `bun:"severity,type:text,default:'medium',notnull," json:"severity"`
|
||||
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||
RelGuardrailIDPublicAgentPersonaGuardrails []*ModelPublicAgentPersonaGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicagentpersonaguardrails,omitempty"` // Has many ModelPublicAgentPersonaGuardrails
|
||||
RelGuardrailIDPublicPlanGuardrails []*ModelPublicPlanGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicplanguardrails,omitempty"` // Has many ModelPublicPlanGuardrails
|
||||
RelGuardrailIDPublicProjectGuardrails []*ModelPublicProjectGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicprojectguardrails,omitempty"` // Has many ModelPublicProjectGuardrails
|
||||
|
||||
@@ -18,7 +18,9 @@ type ModelPublicAgentParts struct {
|
||||
PartType sql_types.SqlString `bun:"part_type,type:text,notnull," json:"part_type"`
|
||||
Summary sql_types.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
||||
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||
RelPartIDPublicAgentPersonaParts []*ModelPublicAgentPersonaParts `bun:"rel:has-many,join:id=part_id" json:"relpartidpublicagentpersonaparts,omitempty"` // Has many ModelPublicAgentPersonaParts
|
||||
RelPartIDPublicArcStageParts []*ModelPublicArcStageParts `bun:"rel:has-many,join:id=part_id" json:"relpartidpublicarcstageparts,omitempty"` // Has many ModelPublicArcStageParts
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ type ModelPublicAgentPersonas struct {
|
||||
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||
Summary sql_types.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
||||
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||
RelPersonaIDPublicAgentPersonaParts []*ModelPublicAgentPersonaParts `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicagentpersonaparts,omitempty"` // Has many ModelPublicAgentPersonaParts
|
||||
RelPersonaIDPublicAgentPersonaSkills []*ModelPublicAgentPersonaSkills `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicagentpersonaskills,omitempty"` // Has many ModelPublicAgentPersonaSkills
|
||||
RelPersonaIDPublicProjectPersonas []*ModelPublicProjectPersonas `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicprojectpersonas,omitempty"` // Has many ModelPublicProjectPersonas
|
||||
|
||||
@@ -3,6 +3,7 @@ package generatedmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
@@ -19,8 +20,10 @@ type ModelPublicAgentSkills struct {
|
||||
LanguageTags sql_types.SqlStringArray `bun:"language_tags,type:text[],default:'{}',notnull," json:"language_tags"`
|
||||
LibraryTags sql_types.SqlStringArray `bun:"library_tags,type:text[],default:'{}',notnull," json:"library_tags"`
|
||||
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||
Tags sql_types.SqlStringArray `bun:"tags,array,type:text[],default:'{}',notnull," json:"tags"`
|
||||
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||
RelSkillIDPublicAgentPersonaSkills []*ModelPublicAgentPersonaSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicagentpersonaskills,omitempty"` // Has many ModelPublicAgentPersonaSkills
|
||||
RelRelatedSkillIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=related_skill_id" json:"relrelatedskillidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
|
||||
RelSkillIDPublicPlanSkills []*ModelPublicPlanSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicplanskills,omitempty"` // Has many ModelPublicPlanSkills
|
||||
|
||||
@@ -16,8 +16,10 @@ type ModelPublicAgentTraits struct {
|
||||
Instruction sql_types.SqlString `bun:"instruction,type:text,default:'',notnull," json:"instruction"`
|
||||
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||
TraitType sql_types.SqlString `bun:"trait_type,type:text,notnull," json:"trait_type"`
|
||||
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||
RelTraitIDPublicAgentPersonaTraits []*ModelPublicAgentPersonaTraits `bun:"rel:has-many,join:id=trait_id" json:"reltraitidpublicagentpersonatraits,omitempty"` // Has many ModelPublicAgentPersonaTraits
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicAPIKeyAssignments struct {
|
||||
bun.BaseModel `bun:"table:public.api_key_assignments,alias:api_key_assignments"`
|
||||
KeyID sql_types.SqlString `bun:"key_id,type:text,pk," json:"key_id"`
|
||||
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||
Enabled bool `bun:"enabled,type:boolean,default:true,notnull," json:"enabled"`
|
||||
Source sql_types.SqlString `bun:"source,type:text,notnull," json:"source"`
|
||||
TenantID sql_types.SqlString `bun:"tenant_id,type:text,notnull," json:"tenant_id"`
|
||||
UserID sql_types.SqlString `bun:"user_id,type:text,nullzero," json:"user_id"`
|
||||
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||
RelUserID *ModelPublicTenantUsers `bun:"rel:has-one,join:user_id=id" json:"reluserid,omitempty"` // Has one ModelPublicTenantUsers
|
||||
RelKeyIDPublicManagedAPIKeys []*ModelPublicManagedAPIKeys `bun:"rel:has-many,join:key_id=key_id" json:"relkeyidpublicmanagedapikeys,omitempty"` // Has many ModelPublicManagedAPIKeys
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicAPIKeyAssignments
|
||||
func (m ModelPublicAPIKeyAssignments) TableName() string {
|
||||
return "public.api_key_assignments"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicAPIKeyAssignments
|
||||
func (m ModelPublicAPIKeyAssignments) TableNameOnly() string {
|
||||
return "api_key_assignments"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicAPIKeyAssignments
|
||||
func (m ModelPublicAPIKeyAssignments) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicAPIKeyAssignments) GetID() string {
|
||||
return m.KeyID.String()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicAPIKeyAssignments) GetIDStr() string {
|
||||
return m.KeyID.String()
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicAPIKeyAssignments) SetID(newid string) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicAPIKeyAssignments) UpdateID(newid string) {
|
||||
m.KeyID.FromString(newid)
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicAPIKeyAssignments) GetIDName() string {
|
||||
return "key_id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicAPIKeyAssignments) GetPrefix() string {
|
||||
return "AKA"
|
||||
}
|
||||
@@ -14,7 +14,9 @@ type ModelPublicCharacterArcs struct {
|
||||
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||
Summary sql_types.SqlString `bun:"summary,type:text,default:'',notnull," json:"summary"`
|
||||
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||
RelArcIDPublicArcStages []*ModelPublicArcStages `bun:"rel:has-many,join:id=arc_id" json:"relarcidpublicarcstages,omitempty"` // Has many ModelPublicArcStages
|
||||
RelArcIDPublicPersonaArcs []*ModelPublicPersonaArc `bun:"rel:has-many,join:id=arc_id" json:"relarcidpublicpersonaarcs,omitempty"` // Has many ModelPublicPersonaArc
|
||||
}
|
||||
|
||||
@@ -19,10 +19,11 @@ type ModelPublicChatHistories struct {
|
||||
ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
||||
SessionID sql_types.SqlString `bun:"session_id,type:text,notnull," json:"session_id"`
|
||||
Summary sql_types.SqlString `bun:"summary,type:text,nullzero," json:"summary"`
|
||||
TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero," json:"tenant_key"`
|
||||
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||
Title sql_types.SqlString `bun:"title,type:text,nullzero," json:"title"`
|
||||
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicChatHistories
|
||||
|
||||
@@ -30,13 +30,14 @@ type ModelPublicLearnings struct {
|
||||
Summary sql_types.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
||||
SupersedesLearningID sql_types.SqlInt64 `bun:"supersedes_learning_id,type:bigint,nullzero," json:"supersedes_learning_id"`
|
||||
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||
TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero," json:"tenant_key"`
|
||||
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelDuplicateOfLearningID *ModelPublicLearnings `bun:"rel:has-one,join:duplicate_of_learning_id=id" json:"relduplicateoflearningid,omitempty"` // Has one ModelPublicLearnings
|
||||
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||
RelRelatedSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:related_skill_id=id" json:"relrelatedskillid,omitempty"` // Has one ModelPublicAgentSkills
|
||||
RelRelatedThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:related_thought_id=id" json:"relrelatedthoughtid,omitempty"` // Has one ModelPublicThoughts
|
||||
RelSupersedesLearningID *ModelPublicLearnings `bun:"rel:has-one,join:supersedes_learning_id=id" json:"relsupersedeslearningid,omitempty"` // Has one ModelPublicLearnings
|
||||
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||
RelLearningIDPublicThoughtLearningLinks []*ModelPublicThoughtLearningLinks `bun:"rel:has-many,join:id=learning_id" json:"rellearningidpublicthoughtlearninglinks,omitempty"` // Has many ModelPublicThoughtLearningLinks
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicManagedAPIKeys struct {
|
||||
bun.BaseModel `bun:"table:public.managed_api_keys,alias:managed_api_keys"`
|
||||
KeyID sql_types.SqlString `bun:"key_id,type:text,pk," json:"key_id"`
|
||||
SecretHash sql_types.SqlString `bun:"secret_hash,type:text,notnull," json:"secret_hash"`
|
||||
RelKeyID *ModelPublicAPIKeyAssignments `bun:"rel:has-one,join:key_id=key_id" json:"relkeyid,omitempty"` // Has one ModelPublicAPIKeyAssignments
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicManagedAPIKeys
|
||||
func (m ModelPublicManagedAPIKeys) TableName() string {
|
||||
return "public.managed_api_keys"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicManagedAPIKeys
|
||||
func (m ModelPublicManagedAPIKeys) TableNameOnly() string {
|
||||
return "managed_api_keys"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicManagedAPIKeys
|
||||
func (m ModelPublicManagedAPIKeys) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicManagedAPIKeys) GetID() string {
|
||||
return m.KeyID.String()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicManagedAPIKeys) GetIDStr() string {
|
||||
return m.KeyID.String()
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicManagedAPIKeys) SetID(newid string) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicManagedAPIKeys) UpdateID(newid string) {
|
||||
m.KeyID.FromString(newid)
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicManagedAPIKeys) GetIDName() string {
|
||||
return "key_id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicManagedAPIKeys) GetPrefix() string {
|
||||
return "MAK"
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
type ModelPublicPersonaArc struct {
|
||||
bun.BaseModel `bun:"table:public.persona_arc,alias:persona_arc"`
|
||||
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||
PersonaID int64 `bun:"persona_id,type:bigint,pk," json:"persona_id"`
|
||||
ArcID int64 `bun:"arc_id,type:bigint,notnull," json:"arc_id"`
|
||||
CurrentStageID int64 `bun:"current_stage_id,type:bigint,notnull," json:"current_stage_id"`
|
||||
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
||||
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelArcID *ModelPublicCharacterArcs `bun:"rel:has-one,join:arc_id=id" json:"relarcid,omitempty"` // Has one ModelPublicCharacterArcs
|
||||
RelCurrentStageID *ModelPublicArcStages `bun:"rel:has-one,join:current_stage_id=id" json:"relcurrentstageid,omitempty"` // Has one ModelPublicArcStages
|
||||
|
||||
@@ -23,11 +23,12 @@ type ModelPublicPlans struct {
|
||||
Status sql_types.SqlString `bun:"status,type:text,default:'draft',notnull," json:"status"` // draft, active, blocked, completed, cancelled, superseded
|
||||
SupersedesPlanID sql_types.SqlInt64 `bun:"supersedes_plan_id,type:bigint,nullzero," json:"supersedes_plan_id"`
|
||||
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||
TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero," json:"tenant_key"`
|
||||
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||
Title sql_types.SqlString `bun:"title,type:text,notnull," json:"title"`
|
||||
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||
RelSupersedesPlanID *ModelPublicPlans `bun:"rel:has-one,join:supersedes_plan_id=id" json:"relsupersedesplanid,omitempty"` // Has one ModelPublicPlans
|
||||
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||
RelDependsOnPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=depends_on_plan_id" json:"reldependsonplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
|
||||
RelPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
|
||||
RelPlanAIDPublicPlanRelatedPlans []*ModelPublicPlanRelatedPlans `bun:"rel:has-many,join:id=plan_a_id" json:"relplanaidpublicplanrelatedplans,omitempty"` // Has many ModelPublicPlanRelatedPlans
|
||||
|
||||
@@ -14,8 +14,9 @@ type ModelPublicProjects struct {
|
||||
Description sql_types.SqlString `bun:"description,type:text,nullzero," json:"description"`
|
||||
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||
LastActiveAt sql_types.SqlTimeStamp `bun:"last_active_at,type:timestamptz,default:now(),nullzero," json:"last_active_at"`
|
||||
Name sql_types.SqlString `bun:"name,type:text,notnull,unique:uidx_projects_tenant_key_name," json:"name"`
|
||||
TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero,unique:uidx_projects_tenant_key_name," json:"tenant_key"`
|
||||
Name sql_types.SqlString `bun:"name,type:text,notnull,unique:uidx_projects_tenant_id_name," json:"name"`
|
||||
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero,unique:uidx_projects_tenant_id_name," json:"tenant_id"`
|
||||
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||
RelProjectIDPublicProjectPersonas []*ModelPublicProjectPersonas `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicprojectpersonas,omitempty"` // Has many ModelPublicProjectPersonas
|
||||
RelProjectIDPublicThoughts []*ModelPublicThoughts `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicthoughts,omitempty"` // Has many ModelPublicThoughts
|
||||
RelProjectIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
|
||||
|
||||
@@ -20,10 +20,11 @@ type ModelPublicStoredFiles struct {
|
||||
ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
||||
Sha256 sql_types.SqlString `bun:"sha256,type:text,notnull," json:"sha256"`
|
||||
SizeBytes int64 `bun:"size_bytes,type:bigint,notnull," json:"size_bytes"`
|
||||
TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero," json:"tenant_key"`
|
||||
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||
ThoughtID sql_types.SqlInt64 `bun:"thought_id,type:bigint,nullzero," json:"thought_id"`
|
||||
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicTenantUsers struct {
|
||||
bun.BaseModel `bun:"table:public.tenant_users,alias:tenant_users"`
|
||||
ID sql_types.SqlString `bun:"id,type:text,pk," json:"id"`
|
||||
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
Email sql_types.SqlString `bun:"email,type:text,nullzero,unique:uidx_tenant_users_tenant_id_email," json:"email"`
|
||||
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||
TenantID sql_types.SqlString `bun:"tenant_id,type:text,notnull,unique:uidx_tenant_users_tenant_id_email," json:"tenant_id"`
|
||||
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||
RelUserIDPublicAPIKeyAssignments []*ModelPublicAPIKeyAssignments `bun:"rel:has-many,join:id=user_id" json:"reluseridpublicapikeyassignments,omitempty"` // Has many ModelPublicAPIKeyAssignments
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicTenantUsers
|
||||
func (m ModelPublicTenantUsers) TableName() string {
|
||||
return "public.tenant_users"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicTenantUsers
|
||||
func (m ModelPublicTenantUsers) TableNameOnly() string {
|
||||
return "tenant_users"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicTenantUsers
|
||||
func (m ModelPublicTenantUsers) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicTenantUsers) GetID() string {
|
||||
return m.ID.String()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicTenantUsers) GetIDStr() string {
|
||||
return m.ID.String()
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicTenantUsers) SetID(newid string) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicTenantUsers) UpdateID(newid string) {
|
||||
m.ID.FromString(newid)
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicTenantUsers) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicTenantUsers) GetPrefix() string {
|
||||
return "TUE"
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Code generated by relspecgo. DO NOT EDIT.
|
||||
package generatedmodels
|
||||
|
||||
import (
|
||||
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type ModelPublicTenants struct {
|
||||
bun.BaseModel `bun:"table:public.tenants,alias:tenants"`
|
||||
ID sql_types.SqlString `bun:"id,type:text,pk," json:"id"`
|
||||
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||
RelTenantIDPublicAgentPersonas []*ModelPublicAgentPersonas `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicagentpersonas,omitempty"` // Has many ModelPublicAgentPersonas
|
||||
RelTenantIDPublicAgentParts []*ModelPublicAgentParts `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicagentparts,omitempty"` // Has many ModelPublicAgentParts
|
||||
RelTenantIDPublicAgentTraits []*ModelPublicAgentTraits `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicagenttraits,omitempty"` // Has many ModelPublicAgentTraits
|
||||
RelTenantIDPublicCharacterArcs []*ModelPublicCharacterArcs `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpubliccharacterarcs,omitempty"` // Has many ModelPublicCharacterArcs
|
||||
RelTenantIDPublicThoughts []*ModelPublicThoughts `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicthoughts,omitempty"` // Has many ModelPublicThoughts
|
||||
RelTenantIDPublicProjects []*ModelPublicProjects `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicprojects,omitempty"` // Has many ModelPublicProjects
|
||||
RelTenantIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
|
||||
RelTenantIDPublicTenantUsers []*ModelPublicTenantUsers `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublictenantusers,omitempty"` // Has many ModelPublicTenantUsers
|
||||
RelTenantIDPublicAPIKeyAssignments []*ModelPublicAPIKeyAssignments `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicapikeyassignments,omitempty"` // Has many ModelPublicAPIKeyAssignments
|
||||
RelTenantIDPublicChatHistories []*ModelPublicChatHistories `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicchathistories,omitempty"` // Has many ModelPublicChatHistories
|
||||
RelTenantIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
|
||||
RelTenantIDPublicPlans []*ModelPublicPlans `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicplans,omitempty"` // Has many ModelPublicPlans
|
||||
RelTenantIDPublicAgentSkills []*ModelPublicAgentSkills `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicagentskills,omitempty"` // Has many ModelPublicAgentSkills
|
||||
RelTenantIDPublicAgentGuardrails []*ModelPublicAgentGuardrails `bun:"rel:has-many,join:id=tenant_id" json:"reltenantidpublicagentguardrails,omitempty"` // Has many ModelPublicAgentGuardrails
|
||||
}
|
||||
|
||||
// TableName returns the table name for ModelPublicTenants
|
||||
func (m ModelPublicTenants) TableName() string {
|
||||
return "public.tenants"
|
||||
}
|
||||
|
||||
// TableNameOnly returns the table name without schema for ModelPublicTenants
|
||||
func (m ModelPublicTenants) TableNameOnly() string {
|
||||
return "tenants"
|
||||
}
|
||||
|
||||
// SchemaName returns the schema name for ModelPublicTenants
|
||||
func (m ModelPublicTenants) SchemaName() string {
|
||||
return "public"
|
||||
}
|
||||
|
||||
// GetID returns the primary key value
|
||||
func (m ModelPublicTenants) GetID() string {
|
||||
return m.ID.String()
|
||||
}
|
||||
|
||||
// GetIDStr returns the primary key as a string
|
||||
func (m ModelPublicTenants) GetIDStr() string {
|
||||
return m.ID.String()
|
||||
}
|
||||
|
||||
// SetID sets the primary key value
|
||||
func (m ModelPublicTenants) SetID(newid string) {
|
||||
m.UpdateID(newid)
|
||||
}
|
||||
|
||||
// UpdateID updates the primary key value
|
||||
func (m *ModelPublicTenants) UpdateID(newid string) {
|
||||
m.ID.FromString(newid)
|
||||
}
|
||||
|
||||
// GetIDName returns the name of the primary key column
|
||||
func (m ModelPublicTenants) GetIDName() string {
|
||||
return "id"
|
||||
}
|
||||
|
||||
// GetPrefix returns the table prefix
|
||||
func (m ModelPublicTenants) GetPrefix() string {
|
||||
return "TEN"
|
||||
}
|
||||
@@ -16,9 +16,10 @@ type ModelPublicThoughts struct {
|
||||
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||
Metadata sql_types.SqlJSONB `bun:"metadata,type:jsonb,default:{}::jsonb,nullzero," json:"metadata"`
|
||||
ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
||||
TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero," json:"tenant_key"`
|
||||
TenantID sql_types.SqlString `bun:"tenant_id,type:text,nullzero," json:"tenant_id"`
|
||||
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),nullzero," json:"updated_at"`
|
||||
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||
RelTenantID *ModelPublicTenants `bun:"rel:has-one,join:tenant_id=id" json:"reltenantid,omitempty"` // Has one ModelPublicTenants
|
||||
RelFromIDPublicThoughtLinks []*ModelPublicThoughtLinks `bun:"rel:has-many,join:id=from_id" json:"relfromidpublicthoughtlinks,omitempty"` // Has many ModelPublicThoughtLinks
|
||||
RelToIDPublicThoughtLinks []*ModelPublicThoughtLinks `bun:"rel:has-many,join:id=to_id" json:"reltoidpublicthoughtlinks,omitempty"` // Has many ModelPublicThoughtLinks
|
||||
RelThoughtIDPublicThoughtLearningLinks []*ModelPublicThoughtLearningLinks `bun:"rel:has-many,join:id=thought_id" json:"relthoughtidpublicthoughtlearninglinks,omitempty"` // Has many ModelPublicThoughtLearningLinks
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
func (db *DB) InsertStoredFile(ctx context.Context, file thoughttypes.StoredFile) (thoughttypes.StoredFile, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into stored_files (thought_id, project_id, tenant_key, name, media_type, kind, encoding, size_bytes, sha256, content)
|
||||
insert into stored_files (thought_id, project_id, tenant_id, name, media_type, kind, encoding, size_bytes, sha256, content)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
returning id, guid, thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, created_at, updated_at
|
||||
`, file.ThoughtID, file.ProjectID, tenantKeyPtr(ctx), file.Name, file.MediaType, file.Kind, file.Encoding, file.SizeBytes, file.SHA256, file.Content)
|
||||
@@ -46,7 +46,7 @@ func (db *DB) GetStoredFile(ctx context.Context, id uuid.UUID) (thoughttypes.Sto
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
select id, guid, thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, content, created_at, updated_at
|
||||
from stored_files
|
||||
where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||
where guid = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
|
||||
var model generatedmodels.ModelPublicStoredFiles
|
||||
if err := row.Scan(
|
||||
@@ -77,7 +77,7 @@ func (db *DB) ListStoredFiles(ctx context.Context, filter thoughttypes.StoredFil
|
||||
args := make([]any, 0, 4)
|
||||
conditions := make([]string, 0, 3)
|
||||
|
||||
addTenantCondition(ctx, &args, &conditions, "tenant_key")
|
||||
addTenantCondition(ctx, &args, &conditions, "tenant_id")
|
||||
if filter.ThoughtID != nil {
|
||||
args = append(args, *filter.ThoughtID)
|
||||
conditions = append(conditions, fmt.Sprintf("thought_id = $%d", len(args)))
|
||||
|
||||
@@ -475,4 +475,3 @@ func canonicalPlanPair(a, b int64) (int64, int64) {
|
||||
}
|
||||
return b, a
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
func (db *DB) CreateProject(ctx context.Context, name, description string) (thoughttypes.Project, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into projects (name, description, tenant_key)
|
||||
insert into projects (name, description, tenant_id)
|
||||
values ($1, $2, $3)
|
||||
returning id, guid, name, description, created_at, last_active_at
|
||||
`, name, description, tenantKeyPtr(ctx))
|
||||
@@ -49,7 +49,7 @@ func (db *DB) getProjectByGUID(ctx context.Context, id uuid.UUID) (thoughttypes.
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
select id, guid, name, description, created_at, last_active_at
|
||||
from projects
|
||||
where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||
where guid = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
return scanProject(row)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func (db *DB) getProjectByName(ctx context.Context, name string) (thoughttypes.P
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
select id, guid, name, description, created_at, last_active_at
|
||||
from projects
|
||||
where name = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||
where name = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
return scanProject(row)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func (db *DB) ListProjects(ctx context.Context) ([]thoughttypes.ProjectSummary,
|
||||
where := ""
|
||||
if key, ok := tenantKey(ctx); ok {
|
||||
args = append(args, key)
|
||||
where = "where p.tenant_key = $1"
|
||||
where = "where p.tenant_id = $1"
|
||||
}
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select p.id, p.guid, p.name, p.description, p.created_at, p.last_active_at, count(t.id) as thought_count
|
||||
@@ -113,7 +113,7 @@ func (db *DB) ListProjects(ctx context.Context) ([]thoughttypes.ProjectSummary,
|
||||
|
||||
func (db *DB) TouchProject(ctx context.Context, id int64) error {
|
||||
args := []any{id}
|
||||
tag, err := db.pool.Exec(ctx, `update projects set last_active_at = now() where id = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||
tag, err := db.pool.Exec(ctx, `update projects set last_active_at = now() where id = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("touch project: %w", err)
|
||||
}
|
||||
|
||||
+25
-15
@@ -28,10 +28,10 @@ func (db *DB) AddSkill(ctx context.Context, skill ext.AgentSkill) (ext.AgentSkil
|
||||
skill.DomainTags = []string{}
|
||||
}
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into agent_skills (name, description, content, tags, language_tags, library_tags, framework_tags, domain_tags)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
insert into agent_skills (name, description, content, tenant_id, tags, language_tags, library_tags, framework_tags, domain_tags)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
returning id, guid, created_at, updated_at
|
||||
`, skill.Name, skill.Description, skill.Content, skill.Tags,
|
||||
`, skill.Name, skill.Description, skill.Content, tenantKeyPtr(ctx), skill.Tags,
|
||||
skill.LanguageTags, skill.LibraryTags, skill.FrameworkTags, skill.DomainTags)
|
||||
|
||||
created := skill
|
||||
@@ -47,7 +47,8 @@ func (db *DB) AddSkill(ctx context.Context, skill ext.AgentSkill) (ext.AgentSkil
|
||||
}
|
||||
|
||||
func (db *DB) RemoveSkill(ctx context.Context, id int64) error {
|
||||
tag, err := db.pool.Exec(ctx, `delete from agent_skills where id = $1`, id)
|
||||
args := []any{id}
|
||||
tag, err := db.pool.Exec(ctx, `delete from agent_skills where id = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete agent skill: %w", err)
|
||||
}
|
||||
@@ -60,9 +61,14 @@ func (db *DB) RemoveSkill(ctx context.Context, id int64) error {
|
||||
func (db *DB) ListSkills(ctx context.Context, tag string) ([]ext.AgentSkill, error) {
|
||||
q := `select id, name, description, content, tags::text[], language_tags::text[], library_tags::text[], framework_tags::text[], domain_tags::text[], created_at, updated_at from agent_skills`
|
||||
args := []any{}
|
||||
conditions := []string{}
|
||||
if t := strings.TrimSpace(tag); t != "" {
|
||||
args = append(args, t)
|
||||
q += fmt.Sprintf(" where $%d = any(tags) or $%d = any(language_tags) or $%d = any(library_tags) or $%d = any(framework_tags) or $%d = any(domain_tags)", len(args), len(args), len(args), len(args), len(args))
|
||||
conditions = append(conditions, fmt.Sprintf("($%d = any(tags) or $%d = any(language_tags) or $%d = any(library_tags) or $%d = any(framework_tags) or $%d = any(domain_tags))", len(args), len(args), len(args), len(args), len(args)))
|
||||
}
|
||||
addTenantCondition(ctx, &args, &conditions, "tenant_id")
|
||||
if len(conditions) > 0 {
|
||||
q += " where " + strings.Join(conditions, " and ")
|
||||
}
|
||||
q += " order by name"
|
||||
|
||||
@@ -135,7 +141,8 @@ func normalizeSkillSlices(skill *ext.AgentSkill) {
|
||||
}
|
||||
|
||||
func (db *DB) GetSkill(ctx context.Context, id int64) (ext.AgentSkill, error) {
|
||||
row := db.pool.QueryRow(ctx, `select `+skillSelectCols+` from agent_skills where id = $1`, id)
|
||||
args := []any{id}
|
||||
row := db.pool.QueryRow(ctx, `select `+skillSelectCols+` from agent_skills where id = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
s, err := scanSkill(row)
|
||||
if err != nil {
|
||||
return ext.AgentSkill{}, fmt.Errorf("get agent skill: %w", err)
|
||||
@@ -144,7 +151,8 @@ func (db *DB) GetSkill(ctx context.Context, id int64) (ext.AgentSkill, error) {
|
||||
}
|
||||
|
||||
func (db *DB) GetSkillByName(ctx context.Context, name string) (ext.AgentSkill, error) {
|
||||
row := db.pool.QueryRow(ctx, `select `+skillSelectCols+` from agent_skills where name = $1`, name)
|
||||
args := []any{name}
|
||||
row := db.pool.QueryRow(ctx, `select `+skillSelectCols+` from agent_skills where name = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
s, err := scanSkill(row)
|
||||
if err != nil {
|
||||
return ext.AgentSkill{}, fmt.Errorf("get agent skill by name: %w", err)
|
||||
@@ -153,10 +161,10 @@ func (db *DB) GetSkillByName(ctx context.Context, name string) (ext.AgentSkill,
|
||||
}
|
||||
|
||||
func (db *DB) GetGuardrailByName(ctx context.Context, name string) (ext.AgentGuardrail, error) {
|
||||
args := []any{name}
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
select id, name, description, content, severity, tags::text[], created_at, updated_at
|
||||
from agent_guardrails where name = $1
|
||||
`, name)
|
||||
from agent_guardrails where name = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
|
||||
var model generatedmodels.ModelPublicAgentGuardrails
|
||||
var tags []string
|
||||
@@ -189,10 +197,10 @@ func (db *DB) AddGuardrail(ctx context.Context, g ext.AgentGuardrail) (ext.Agent
|
||||
g.Severity = "medium"
|
||||
}
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into agent_guardrails (name, description, content, severity, tags)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
insert into agent_guardrails (name, description, content, severity, tenant_id, tags)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning id, guid, created_at, updated_at
|
||||
`, g.Name, g.Description, g.Content, g.Severity, g.Tags)
|
||||
`, g.Name, g.Description, g.Content, g.Severity, tenantKeyPtr(ctx), g.Tags)
|
||||
|
||||
created := g
|
||||
var model generatedmodels.ModelPublicAgentGuardrails
|
||||
@@ -207,7 +215,8 @@ func (db *DB) AddGuardrail(ctx context.Context, g ext.AgentGuardrail) (ext.Agent
|
||||
}
|
||||
|
||||
func (db *DB) RemoveGuardrail(ctx context.Context, id int64) error {
|
||||
tag, err := db.pool.Exec(ctx, `delete from agent_guardrails where id = $1`, id)
|
||||
args := []any{id}
|
||||
tag, err := db.pool.Exec(ctx, `delete from agent_guardrails where id = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete agent guardrail: %w", err)
|
||||
}
|
||||
@@ -229,6 +238,7 @@ func (db *DB) ListGuardrails(ctx context.Context, tag, severity string) ([]ext.A
|
||||
args = append(args, s)
|
||||
conditions = append(conditions, fmt.Sprintf("severity = $%d", len(args)))
|
||||
}
|
||||
addTenantCondition(ctx, &args, &conditions, "tenant_id")
|
||||
|
||||
q := `select id, name, description, content, severity, tags::text[], created_at, updated_at from agent_guardrails`
|
||||
if len(conditions) > 0 {
|
||||
@@ -268,10 +278,10 @@ func (db *DB) ListGuardrails(ctx context.Context, tag, severity string) ([]ext.A
|
||||
}
|
||||
|
||||
func (db *DB) GetGuardrail(ctx context.Context, id int64) (ext.AgentGuardrail, error) {
|
||||
args := []any{id}
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
select id, name, description, content, severity, tags::text[], created_at, updated_at
|
||||
from agent_guardrails where id = $1
|
||||
`, id)
|
||||
from agent_guardrails where id = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
|
||||
var model generatedmodels.ModelPublicAgentGuardrails
|
||||
var tags []string
|
||||
|
||||
+14
-14
@@ -31,7 +31,7 @@ func (db *DB) InsertThought(ctx context.Context, thought thoughttypes.Thought, e
|
||||
}()
|
||||
|
||||
row := tx.QueryRow(ctx, `
|
||||
insert into thoughts (content, metadata, project_id, tenant_key)
|
||||
insert into thoughts (content, metadata, project_id, tenant_id)
|
||||
values ($1, $2::jsonb, $3, $4)
|
||||
returning id, guid, created_at, updated_at
|
||||
`, thought.Content, metadata, thought.ProjectID, tenantKeyPtr(ctx))
|
||||
@@ -123,7 +123,7 @@ func (db *DB) ListThoughts(ctx context.Context, filter thoughttypes.ListFilter)
|
||||
args := make([]any, 0, 6)
|
||||
conditions := []string{}
|
||||
|
||||
addTenantCondition(ctx, &args, &conditions, "tenant_key")
|
||||
addTenantCondition(ctx, &args, &conditions, "tenant_id")
|
||||
if !filter.IncludeArchived {
|
||||
conditions = append(conditions, "archived_at is null")
|
||||
}
|
||||
@@ -189,7 +189,7 @@ func (db *DB) Stats(ctx context.Context) (thoughttypes.ThoughtStats, error) {
|
||||
var total int
|
||||
statsArgs := []any{}
|
||||
statsConditions := []string{"archived_at is null"}
|
||||
addTenantCondition(ctx, &statsArgs, &statsConditions, "tenant_key")
|
||||
addTenantCondition(ctx, &statsArgs, &statsConditions, "tenant_id")
|
||||
if err := db.pool.QueryRow(ctx, `select count(*) from thoughts where `+strings.Join(statsConditions, " and "), statsArgs...).Scan(&total); err != nil {
|
||||
return thoughttypes.ThoughtStats{}, fmt.Errorf("count thoughts: %w", err)
|
||||
}
|
||||
@@ -241,7 +241,7 @@ func (db *DB) GetThought(ctx context.Context, id uuid.UUID) (thoughttypes.Though
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
|
||||
from thoughts
|
||||
where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||
where guid = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
|
||||
var model generatedmodels.ModelPublicThoughts
|
||||
if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
@@ -264,7 +264,7 @@ func (db *DB) GetThoughtByID(ctx context.Context, id int64) (thoughttypes.Though
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
|
||||
from thoughts
|
||||
where id = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||
where id = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
|
||||
var model generatedmodels.ModelPublicThoughts
|
||||
if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
@@ -303,7 +303,7 @@ func (db *DB) UpdateThought(ctx context.Context, id uuid.UUID, content string, e
|
||||
metadata = $3::jsonb,
|
||||
project_id = $4,
|
||||
updated_at = now()
|
||||
where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||
where guid = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
if err != nil {
|
||||
return thoughttypes.Thought{}, fmt.Errorf("update thought: %w", err)
|
||||
}
|
||||
@@ -342,7 +342,7 @@ func (db *DB) UpdateThoughtMetadata(ctx context.Context, id int64, metadata thou
|
||||
update thoughts
|
||||
set metadata = $2::jsonb,
|
||||
updated_at = now()
|
||||
where id = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||
where id = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
if err != nil {
|
||||
return thoughttypes.Thought{}, fmt.Errorf("update thought metadata: %w", err)
|
||||
}
|
||||
@@ -355,7 +355,7 @@ func (db *DB) UpdateThoughtMetadata(ctx context.Context, id int64, metadata thou
|
||||
|
||||
func (db *DB) DeleteThought(ctx context.Context, id uuid.UUID) error {
|
||||
args := []any{id}
|
||||
tag, err := db.pool.Exec(ctx, `delete from thoughts where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||
tag, err := db.pool.Exec(ctx, `delete from thoughts where guid = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete thought: %w", err)
|
||||
}
|
||||
@@ -367,7 +367,7 @@ func (db *DB) DeleteThought(ctx context.Context, id uuid.UUID) error {
|
||||
|
||||
func (db *DB) ArchiveThought(ctx context.Context, id uuid.UUID) error {
|
||||
args := []any{id}
|
||||
tag, err := db.pool.Exec(ctx, `update thoughts set archived_at = now(), updated_at = now() where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||
tag, err := db.pool.Exec(ctx, `update thoughts set archived_at = now(), updated_at = now() where guid = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("archive thought: %w", err)
|
||||
}
|
||||
@@ -446,7 +446,7 @@ func (db *DB) SearchSimilarThoughts(ctx context.Context, embedding []float32, em
|
||||
"1 - (e.embedding <=> $1) > $2",
|
||||
"e.model = $3",
|
||||
}
|
||||
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
|
||||
addTenantCondition(ctx, &args, &conditions, "t.tenant_id")
|
||||
if projectID != nil {
|
||||
args = append(args, *projectID)
|
||||
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
|
||||
@@ -495,7 +495,7 @@ func (db *DB) HasEmbeddingsForModel(ctx context.Context, model string, projectID
|
||||
"e.model = $1",
|
||||
"t.archived_at is null",
|
||||
}
|
||||
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
|
||||
addTenantCondition(ctx, &args, &conditions, "t.tenant_id")
|
||||
if projectID != nil {
|
||||
args = append(args, *projectID)
|
||||
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
|
||||
@@ -514,7 +514,7 @@ func (db *DB) HasEmbeddingsForModel(ctx context.Context, model string, projectID
|
||||
func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, limit int, projectID *int64, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
|
||||
args := []any{model}
|
||||
conditions := []string{"e.id is null"}
|
||||
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
|
||||
addTenantCondition(ctx, &args, &conditions, "t.tenant_id")
|
||||
|
||||
if !includeArchived {
|
||||
conditions = append(conditions, "t.archived_at is null")
|
||||
@@ -564,7 +564,7 @@ func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, li
|
||||
func (db *DB) ListThoughtsForMetadataReparse(ctx context.Context, limit int, projectID *int64, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
|
||||
args := make([]any, 0, 3)
|
||||
conditions := make([]string, 0, 4)
|
||||
addTenantCondition(ctx, &args, &conditions, "tenant_key")
|
||||
addTenantCondition(ctx, &args, &conditions, "tenant_id")
|
||||
|
||||
if !includeArchived {
|
||||
conditions = append(conditions, "archived_at is null")
|
||||
@@ -634,7 +634,7 @@ func (db *DB) SearchThoughtsText(ctx context.Context, query string, limit int, p
|
||||
"t.archived_at is null",
|
||||
"(to_tsvector('simple', t.content) || to_tsvector('simple', coalesce(p.name, ''))) @@ websearch_to_tsquery('simple', $1)",
|
||||
}
|
||||
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
|
||||
addTenantCondition(ctx, &args, &conditions, "t.tenant_id")
|
||||
if projectID != nil {
|
||||
args = append(args, *projectID)
|
||||
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
|
||||
|
||||
+1346
-109
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
create table if not exists tenants (
|
||||
id text primary key,
|
||||
name text not null unique,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists tenant_users (
|
||||
id text primary key,
|
||||
tenant_id text not null references tenants(id),
|
||||
name text not null,
|
||||
email text,
|
||||
created_at timestamptz not null default now(),
|
||||
unique (tenant_id, email)
|
||||
);
|
||||
|
||||
create index if not exists tenant_users_tenant_id_idx on tenant_users (tenant_id);
|
||||
|
||||
create table if not exists api_key_assignments (
|
||||
key_id text primary key,
|
||||
tenant_id text not null references tenants(id),
|
||||
user_id text references tenant_users(id),
|
||||
description text not null default '',
|
||||
source text not null check (source in ('configured', 'managed')),
|
||||
enabled boolean not null default true,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table if not exists managed_api_keys (
|
||||
key_id text primary key references api_key_assignments(key_id) on delete cascade,
|
||||
secret_hash text not null
|
||||
);
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Convert tenant_key from an opaque authentication value into a real tenant
|
||||
-- relationship. Preserve legacy rows and create placeholders for historical
|
||||
-- key IDs before adding foreign keys.
|
||||
insert into tenants (id, name)
|
||||
select tenant_key, tenant_key
|
||||
from (
|
||||
select tenant_key from projects
|
||||
union select tenant_key from thoughts
|
||||
union select tenant_key from stored_files
|
||||
union select tenant_key from learnings
|
||||
union select tenant_key from plans
|
||||
union select tenant_key from chat_histories
|
||||
) tenant_keys
|
||||
where tenant_key is not null and tenant_key <> ''
|
||||
on conflict (id) do nothing;
|
||||
|
||||
alter table agent_skills add column if not exists tenant_key text references tenants(id);
|
||||
alter table agent_guardrails add column if not exists tenant_key text references tenants(id);
|
||||
alter table agent_personas add column if not exists tenant_key text references tenants(id);
|
||||
alter table agent_parts add column if not exists tenant_key text references tenants(id);
|
||||
alter table agent_traits add column if not exists tenant_key text references tenants(id);
|
||||
alter table character_arcs add column if not exists tenant_key text references tenants(id);
|
||||
|
||||
alter table agent_skills drop constraint if exists ukey_agent_skills_name;
|
||||
alter table agent_guardrails drop constraint if exists ukey_agent_guardrails_name;
|
||||
alter table agent_personas drop constraint if exists ukey_agent_personas_name;
|
||||
alter table agent_parts drop constraint if exists ukey_agent_parts_name;
|
||||
alter table agent_traits drop constraint if exists ukey_agent_traits_name;
|
||||
alter table character_arcs drop constraint if exists ukey_character_arcs_name;
|
||||
|
||||
create unique index if not exists agent_skills_tenant_key_name_idx on agent_skills (coalesce(tenant_key, ''), name);
|
||||
create unique index if not exists agent_guardrails_tenant_key_name_idx on agent_guardrails (coalesce(tenant_key, ''), name);
|
||||
create unique index if not exists agent_personas_tenant_key_name_idx on agent_personas (coalesce(tenant_key, ''), name);
|
||||
create unique index if not exists agent_parts_tenant_key_name_idx on agent_parts (coalesce(tenant_key, ''), name);
|
||||
create unique index if not exists agent_traits_tenant_key_name_idx on agent_traits (coalesce(tenant_key, ''), name);
|
||||
create unique index if not exists character_arcs_tenant_key_name_idx on character_arcs (coalesce(tenant_key, ''), name);
|
||||
|
||||
create index if not exists agent_skills_tenant_key_idx on agent_skills (tenant_key);
|
||||
create index if not exists agent_guardrails_tenant_key_idx on agent_guardrails (tenant_key);
|
||||
create index if not exists agent_personas_tenant_key_idx on agent_personas (tenant_key);
|
||||
create index if not exists agent_parts_tenant_key_idx on agent_parts (tenant_key);
|
||||
create index if not exists agent_traits_tenant_key_idx on agent_traits (tenant_key);
|
||||
create index if not exists character_arcs_tenant_key_idx on character_arcs (tenant_key);
|
||||
|
||||
do $$
|
||||
declare table_name text;
|
||||
begin
|
||||
foreach table_name in array array['projects', 'thoughts', 'stored_files', 'learnings', 'plans', 'chat_histories']
|
||||
loop
|
||||
execute format('alter table %I drop constraint if exists %I', table_name, table_name || '_tenant_key_fkey');
|
||||
execute format('alter table %I add constraint %I foreign key (tenant_key) references tenants(id)', table_name, table_name || '_tenant_key_fkey');
|
||||
end loop;
|
||||
end $$;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Tenant data is not in use yet, so replace the earlier opaque tenant_key
|
||||
-- column name with the relational tenant_id convention everywhere.
|
||||
do $$
|
||||
declare
|
||||
tbl text;
|
||||
con text;
|
||||
begin
|
||||
foreach tbl in array array[
|
||||
'projects', 'thoughts', 'stored_files', 'learnings', 'plans', 'chat_histories',
|
||||
'agent_skills', 'agent_guardrails', 'agent_personas', 'agent_parts',
|
||||
'agent_traits', 'character_arcs'
|
||||
] loop
|
||||
-- Fresh installs already have tenant_id from the regenerated schema;
|
||||
-- discard that empty column so the historical migration chain converges.
|
||||
if exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = tbl and column_name = 'tenant_id'
|
||||
) and exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = tbl and column_name = 'tenant_key'
|
||||
) then
|
||||
execute format('alter table %I drop column tenant_id', tbl);
|
||||
end if;
|
||||
if exists (
|
||||
select 1 from information_schema.columns
|
||||
where table_schema = 'public' and table_name = tbl and column_name = 'tenant_key'
|
||||
) then
|
||||
execute format('alter table %I rename column tenant_key to tenant_id', tbl);
|
||||
end if;
|
||||
|
||||
for con in
|
||||
select c.conname
|
||||
from pg_constraint c
|
||||
join pg_class r on r.oid = c.conrelid
|
||||
join pg_namespace n on n.oid = r.relnamespace
|
||||
join unnest(c.conkey) as k(attnum) on true
|
||||
join pg_attribute a on a.attrelid = r.oid and a.attnum = k.attnum
|
||||
where n.nspname = 'public' and r.relname = tbl and c.contype = 'f' and a.attname = 'tenant_id'
|
||||
loop
|
||||
execute format('alter table %I drop constraint %I', tbl, con);
|
||||
end loop;
|
||||
execute format('alter table %I add constraint %I foreign key (tenant_id) references tenants(id)', tbl, 'fk_' || tbl || '_tenant_id');
|
||||
end loop;
|
||||
end $$;
|
||||
@@ -1,7 +1,8 @@
|
||||
Table agent_personas {
|
||||
id bigserial [pk]
|
||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||
name text [unique, not null]
|
||||
name text [not null]
|
||||
tenant_id text [ref: > tenants.id]
|
||||
description text [not null, default: '']
|
||||
summary text [not null]
|
||||
detail text [not null, default: '']
|
||||
@@ -11,12 +12,15 @@ Table agent_personas {
|
||||
tags "text[]" [not null, default: `'{}'`]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes { (tenant_id, name) [unique] tenant_id }
|
||||
}
|
||||
|
||||
Table agent_parts {
|
||||
id bigserial [pk]
|
||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||
name text [unique, not null]
|
||||
name text [not null]
|
||||
tenant_id text [ref: > tenants.id]
|
||||
part_type text [not null]
|
||||
description text [not null, default: '']
|
||||
summary text [not null]
|
||||
@@ -24,6 +28,8 @@ Table agent_parts {
|
||||
tags "text[]" [not null, default: `'{}'`]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes { (tenant_id, name) [unique] tenant_id }
|
||||
}
|
||||
|
||||
Table agent_persona_parts {
|
||||
@@ -77,13 +83,16 @@ Table agent_persona_guardrails {
|
||||
Table agent_traits {
|
||||
id bigserial [pk]
|
||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||
name text [unique, not null]
|
||||
name text [not null]
|
||||
tenant_id text [ref: > tenants.id]
|
||||
trait_type text [not null]
|
||||
description text [not null, default: '']
|
||||
instruction text [not null, default: '']
|
||||
tags "text[]" [not null, default: `'{}'`]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes { (tenant_id, name) [unique] tenant_id }
|
||||
}
|
||||
|
||||
Table agent_persona_traits {
|
||||
@@ -98,11 +107,14 @@ Table agent_persona_traits {
|
||||
|
||||
Table character_arcs {
|
||||
id bigserial [pk]
|
||||
name text [unique, not null]
|
||||
name text [not null]
|
||||
tenant_id text [ref: > tenants.id]
|
||||
description text [not null, default: '']
|
||||
summary text [not null, default: '']
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes { (tenant_id, name) [unique] tenant_id }
|
||||
}
|
||||
|
||||
Table arc_stages {
|
||||
@@ -127,7 +139,7 @@ Table arc_stage_parts {
|
||||
|
||||
Table persona_arc {
|
||||
id bigserial [pk]
|
||||
persona_id bigint [pk, ref: > agent_personas.id]
|
||||
persona_id bigint [unique, not null, ref: > agent_personas.id]
|
||||
arc_id bigint [not null, ref: > character_arcs.id]
|
||||
current_stage_id bigint [not null, ref: > arc_stages.id]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
+6
-6
@@ -6,12 +6,12 @@ Table thoughts {
|
||||
created_at timestamptz [default: `now()`]
|
||||
updated_at timestamptz [default: `now()`]
|
||||
project_id bigint [ref: > projects.id]
|
||||
tenant_key text
|
||||
tenant_id text [ref: > tenants.id]
|
||||
archived_at timestamptz
|
||||
|
||||
indexes {
|
||||
tenant_key
|
||||
(tenant_key, project_id)
|
||||
tenant_id
|
||||
(tenant_id, project_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,13 +20,13 @@ Table projects {
|
||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||
name text [not null]
|
||||
description text
|
||||
tenant_key text
|
||||
tenant_id text [ref: > tenants.id]
|
||||
created_at timestamptz [default: `now()`]
|
||||
last_active_at timestamptz [default: `now()`]
|
||||
|
||||
indexes {
|
||||
(tenant_key, name) [unique]
|
||||
tenant_key
|
||||
(tenant_id, name) [unique]
|
||||
tenant_id
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ Table stored_files {
|
||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||
thought_id bigint [ref: > thoughts.id]
|
||||
project_id bigint [ref: > projects.id]
|
||||
tenant_key text
|
||||
tenant_id text [ref: > tenants.id]
|
||||
name text [not null]
|
||||
media_type text [not null]
|
||||
kind text [not null, default: 'file']
|
||||
@@ -17,7 +17,7 @@ Table stored_files {
|
||||
indexes {
|
||||
thought_id
|
||||
project_id
|
||||
tenant_key
|
||||
tenant_id
|
||||
sha256
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
Table tenants {
|
||||
id text [pk]
|
||||
name text [not null, unique]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
}
|
||||
|
||||
Table tenant_users {
|
||||
id text [pk]
|
||||
tenant_id text [not null, ref: > tenants.id]
|
||||
name text [not null]
|
||||
email text
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
|
||||
Indexes {
|
||||
tenant_id
|
||||
(tenant_id, email) [unique]
|
||||
}
|
||||
}
|
||||
|
||||
Table api_key_assignments {
|
||||
key_id text [pk]
|
||||
tenant_id text [not null, ref: > tenants.id]
|
||||
user_id text [ref: > tenant_users.id]
|
||||
description text [not null, default: '']
|
||||
source text [not null]
|
||||
enabled boolean [not null, default: true]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
}
|
||||
|
||||
Table managed_api_keys {
|
||||
key_id text [pk, ref: > api_key_assignments.key_id]
|
||||
secret_hash text [not null]
|
||||
}
|
||||
+4
-4
@@ -6,7 +6,7 @@ Table chat_histories {
|
||||
channel text
|
||||
agent_id text
|
||||
project_id bigint [ref: > projects.id]
|
||||
tenant_key text
|
||||
tenant_id text [ref: > tenants.id]
|
||||
messages jsonb [not null, default: `'[]'`]
|
||||
summary text
|
||||
metadata jsonb [not null, default: `'{}'`]
|
||||
@@ -16,7 +16,7 @@ Table chat_histories {
|
||||
indexes {
|
||||
session_id
|
||||
project_id
|
||||
tenant_key
|
||||
tenant_id
|
||||
channel
|
||||
agent_id
|
||||
created_at
|
||||
@@ -48,7 +48,7 @@ Table learnings {
|
||||
source_type text
|
||||
source_ref text
|
||||
project_id bigint [ref: > projects.id]
|
||||
tenant_key text
|
||||
tenant_id text [ref: > tenants.id]
|
||||
related_thought_id bigint [ref: > thoughts.id]
|
||||
related_skill_id bigint [ref: > agent_skills.id]
|
||||
reviewed_by text
|
||||
@@ -61,7 +61,7 @@ Table learnings {
|
||||
|
||||
indexes {
|
||||
project_id
|
||||
tenant_key
|
||||
tenant_id
|
||||
category
|
||||
area
|
||||
status
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ Table plans {
|
||||
status text [not null, default: 'draft'] // draft, active, blocked, completed, cancelled, superseded
|
||||
priority text [not null, default: 'medium'] // low, medium, high, critical
|
||||
project_id bigint [ref: > projects.id]
|
||||
tenant_key text
|
||||
tenant_id text [ref: > tenants.id]
|
||||
owner text
|
||||
due_date timestamptz
|
||||
completed_at timestamptz
|
||||
@@ -19,7 +19,7 @@ Table plans {
|
||||
|
||||
indexes {
|
||||
project_id
|
||||
tenant_key
|
||||
tenant_id
|
||||
status
|
||||
priority
|
||||
owner
|
||||
|
||||
+8
-2
@@ -1,7 +1,8 @@
|
||||
Table agent_skills {
|
||||
id bigserial [pk]
|
||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||
name text [unique, not null]
|
||||
name text [not null]
|
||||
tenant_id text [ref: > tenants.id]
|
||||
description text [not null, default: '']
|
||||
content text [not null]
|
||||
tags "text[]" [not null, default: `'{}'`]
|
||||
@@ -11,18 +12,23 @@ Table agent_skills {
|
||||
domain_tags "text[]" [not null, default: `'{}'`]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes { (tenant_id, name) [unique] tenant_id }
|
||||
}
|
||||
|
||||
Table agent_guardrails {
|
||||
id bigserial [pk]
|
||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||
name text [unique, not null]
|
||||
name text [not null]
|
||||
tenant_id text [ref: > tenants.id]
|
||||
description text [not null, default: '']
|
||||
content text [not null]
|
||||
severity text [not null, default: 'medium']
|
||||
tags "text[]" [not null, default: `'{}'`]
|
||||
created_at timestamptz [not null, default: `now()`]
|
||||
updated_at timestamptz [not null, default: `now()`]
|
||||
|
||||
indexes { (tenant_id, name) [unique] tenant_id }
|
||||
}
|
||||
|
||||
Table project_skills {
|
||||
|
||||
+10
-10
@@ -11,22 +11,22 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@types/node": "^25.6.0",
|
||||
"svelte": "^5.55.5",
|
||||
"svelte-check": "^4.4.6",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.1.1",
|
||||
"svelte": "^5.56.6",
|
||||
"svelte-check": "^4.7.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10"
|
||||
"vite": "^8.1.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/svelte": "^10.51.0",
|
||||
"@sentry/svelte": "^10.67.0",
|
||||
"@skeletonlabs/skeleton": "^4.15.2",
|
||||
"@skeletonlabs/skeleton-svelte": "^4.15.2",
|
||||
"@tanstack/svelte-virtual": "^3.13.24",
|
||||
"@tanstack/svelte-virtual": "^3.13.33",
|
||||
"@warkypublic/artemis-kit": "^1.0.10",
|
||||
"@warkypublic/resolvespec-js": "^1.0.1",
|
||||
"@warkypublic/svelix": "^0.1.40"
|
||||
"@warkypublic/svelix": "^0.2.5"
|
||||
}
|
||||
}
|
||||
Generated
+435
-390
File diff suppressed because it is too large
Load Diff
+84
-3
@@ -1,8 +1,9 @@
|
||||
import { GlobalStateStore } from './shellState';
|
||||
import { currentTenantID, tenantScopeHeaders } from './tenantScope';
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const token = GlobalStateStore.getState().session.authToken;
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
return { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...tenantScopeHeaders() };
|
||||
}
|
||||
|
||||
type ResolveSpecResponse<T> = {
|
||||
@@ -18,6 +19,62 @@ type ResolveSpecFilter = {
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
const TENANT_OWNED_RESOLVE_SPEC_ENTITIES = new Set([
|
||||
'projects',
|
||||
'thoughts',
|
||||
'learnings',
|
||||
'plans',
|
||||
'stored_files',
|
||||
'agent_skills',
|
||||
'agent_guardrails',
|
||||
'agent_personas',
|
||||
'agent_parts',
|
||||
'agent_traits',
|
||||
'character_arcs',
|
||||
'chat_histories'
|
||||
]);
|
||||
|
||||
function resolveSpecEntity(path: string): string | null {
|
||||
const match = path.match(/^\/api\/rs\/public\/([^/?]+)/);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function tenantScopedResolveSpecPayload(
|
||||
path: string,
|
||||
operation: 'read' | 'create' | 'update' | 'delete',
|
||||
payload?: { data?: unknown; options?: unknown }
|
||||
): { data?: unknown; options?: unknown } | undefined {
|
||||
const entity = resolveSpecEntity(path);
|
||||
const tenantID = currentTenantID();
|
||||
if (!entity || !tenantID || !TENANT_OWNED_RESOLVE_SPEC_ENTITIES.has(entity)) return payload;
|
||||
|
||||
if (operation === 'create') {
|
||||
const data = payload?.data;
|
||||
return {
|
||||
...payload,
|
||||
// A selected tenant is authoritative; do not permit a form value to
|
||||
// accidentally create data in a different tenant.
|
||||
data: data && typeof data === 'object' && !Array.isArray(data) ? { ...data, tenant_id: tenantID } : data
|
||||
};
|
||||
}
|
||||
|
||||
const existingOptions = payload?.options && typeof payload.options === 'object' && !Array.isArray(payload.options)
|
||||
? payload.options as Record<string, unknown>
|
||||
: {};
|
||||
const filters = Array.isArray(existingOptions.filters)
|
||||
? existingOptions.filters.filter((filter): filter is ResolveSpecFilter =>
|
||||
Boolean(filter) && typeof filter === 'object' && (filter as ResolveSpecFilter).column !== 'tenant_id')
|
||||
: [];
|
||||
|
||||
return {
|
||||
...payload,
|
||||
options: {
|
||||
...existingOptions,
|
||||
filters: [...filters, { column: 'tenant_id', operator: 'eq', value: tenantID }]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((tag) => String(tag).trim()).filter(Boolean);
|
||||
@@ -70,18 +127,29 @@ async function del(path: string): Promise<void> {
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
async function patch<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function rsCall<T>(
|
||||
path: string,
|
||||
operation: 'read' | 'create' | 'update' | 'delete',
|
||||
payload?: { data?: unknown; options?: unknown }
|
||||
): Promise<T> {
|
||||
const scopedPayload = tenantScopedResolveSpecPayload(path, operation, payload);
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({
|
||||
operation,
|
||||
...(payload?.data !== undefined ? { data: payload.data } : {}),
|
||||
...(payload?.options !== undefined ? { options: payload.options } : {})
|
||||
...(scopedPayload?.data !== undefined ? { data: scopedPayload.data } : {}),
|
||||
...(scopedPayload?.options !== undefined ? { options: scopedPayload.options } : {})
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
@@ -333,6 +401,19 @@ export const api = {
|
||||
dry_run: input?.dry_run ?? false
|
||||
})
|
||||
},
|
||||
identity: {
|
||||
get: () => get<import('./types').IdentityData>('/api/admin/identity'),
|
||||
createTenant: (name: string) =>
|
||||
post<import('./types').Tenant>('/api/admin/identity/tenants', { name }),
|
||||
adoptLegacy: (id: string) =>
|
||||
post<unknown>(`/api/admin/identity/tenants/${id}/adopt-legacy`, {}),
|
||||
createUser: (data: { tenant_id: string; name: string; email: string }) =>
|
||||
post<import('./types').TenantUser>('/api/admin/identity/users', data),
|
||||
createKey: (data: { tenant_id: string; user_id?: string; description: string }) =>
|
||||
post<{ key: import('./types').IdentityKey; secret: string }>('/api/admin/identity/keys', data),
|
||||
updateKey: (id: string, data: { tenant_id: string; user_id?: string | null; description?: string; enabled?: boolean }) =>
|
||||
patch<import('./types').IdentityKey>(`/api/admin/identity/keys/${id}`, data)
|
||||
},
|
||||
plans: {
|
||||
list: async (params?: { status?: string; priority?: string; project_id?: string; limit?: number }) => {
|
||||
const filters: ResolveSpecFilter[] = [];
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '../../api';
|
||||
import type { IdentityData, IdentityKey, Tenant, TenantUser } from '../../types';
|
||||
import BooleanStatusBadge from '../shared/BooleanStatusBadge.svelte';
|
||||
|
||||
let identity = $state<IdentityData>({ tenants: [], users: [], keys: [] });
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let message = $state('');
|
||||
let busy = $state(false);
|
||||
let tenantName = $state('');
|
||||
let userTenantID = $state('');
|
||||
let userName = $state('');
|
||||
let userEmail = $state('');
|
||||
let keyTenantID = $state('');
|
||||
let keyUserID = $state('');
|
||||
let keyDescription = $state('');
|
||||
let revealedSecret = $state<string | null>(null);
|
||||
|
||||
const usersForTenant = (tenantID: string): TenantUser[] =>
|
||||
identity.users.filter((user) => user.tenant_id === tenantID);
|
||||
|
||||
function tenantNameFor(id: string): string {
|
||||
return identity.tenants.find((tenant) => tenant.id === id)?.name ?? id;
|
||||
}
|
||||
|
||||
function userNameFor(id?: string): string {
|
||||
if (!id) return 'Unassigned';
|
||||
const user = identity.users.find((candidate) => candidate.id === id);
|
||||
return user ? `${user.name} (${user.email})` : id;
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
identity = await api.identity.get();
|
||||
if (!userTenantID && identity.tenants[0]) userTenantID = identity.tenants[0].id;
|
||||
if (!keyTenantID && identity.tenants[0]) keyTenantID = identity.tenants[0].id;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Failed to load identity data.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createTenant() {
|
||||
if (!tenantName.trim()) return;
|
||||
busy = true; error = ''; message = '';
|
||||
try {
|
||||
const tenant = await api.identity.createTenant(tenantName.trim());
|
||||
identity.tenants = [...identity.tenants, tenant];
|
||||
userTenantID ||= tenant.id;
|
||||
keyTenantID ||= tenant.id;
|
||||
tenantName = '';
|
||||
message = `Created tenant ${tenant.name}.`;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Failed to create tenant.';
|
||||
} finally { busy = false; }
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
if (!userTenantID || !userName.trim() || !userEmail.trim()) return;
|
||||
busy = true; error = ''; message = '';
|
||||
try {
|
||||
const user = await api.identity.createUser({ tenant_id: userTenantID, name: userName.trim(), email: userEmail.trim() });
|
||||
identity.users = [...identity.users, user];
|
||||
userName = ''; userEmail = '';
|
||||
message = `Created user ${user.name}.`;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Failed to create user.';
|
||||
} finally { busy = false; }
|
||||
}
|
||||
|
||||
async function createKey() {
|
||||
if (!keyTenantID || !keyDescription.trim()) return;
|
||||
busy = true; error = ''; message = ''; revealedSecret = null;
|
||||
try {
|
||||
const result = await api.identity.createKey({
|
||||
tenant_id: keyTenantID,
|
||||
...(keyUserID ? { user_id: keyUserID } : {}),
|
||||
description: keyDescription.trim()
|
||||
});
|
||||
identity.keys = [...identity.keys, result.key];
|
||||
keyDescription = '';
|
||||
revealedSecret = result.secret;
|
||||
message = `Created key ${result.key.id}. Copy the secret now; it will not be shown again.`;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Failed to create key.';
|
||||
} finally { busy = false; }
|
||||
}
|
||||
|
||||
async function adoptLegacy(tenant: Tenant) {
|
||||
if (!window.confirm(`Move all unassigned legacy data to ${tenant.name}? This cannot be undone from the UI.`)) return;
|
||||
busy = true; error = ''; message = '';
|
||||
try {
|
||||
await api.identity.adoptLegacy(tenant.id);
|
||||
message = `Moved legacy data to ${tenant.name}.`;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Failed to adopt legacy data.';
|
||||
} finally { busy = false; }
|
||||
}
|
||||
|
||||
async function updateKey(key: IdentityKey, field: 'tenant' | 'user' | 'description' | 'enabled', value: string | boolean) {
|
||||
busy = true; error = ''; message = '';
|
||||
try {
|
||||
const data = {
|
||||
tenant_id: field === 'tenant' ? String(value) : key.tenant_id,
|
||||
...(field === 'user' ? { user_id: value ? String(value) : null } : { user_id: key.user_id }),
|
||||
...(field === 'description' ? { description: String(value) } : {}),
|
||||
...(field === 'enabled' ? { enabled: Boolean(value) } : {})
|
||||
};
|
||||
const updated = await api.identity.updateKey(key.id, data);
|
||||
identity.keys = identity.keys.map((candidate) => candidate.id === updated.id ? updated : candidate);
|
||||
message = `Updated key ${updated.id}.`;
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Failed to update key.';
|
||||
} finally { busy = false; }
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Identity</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">Group multiple API keys under a tenant, optionally assigning each key to a user.</p>
|
||||
</div>
|
||||
<button class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 hover:bg-white/10" onclick={load} disabled={loading || busy}>Refresh</button>
|
||||
</div>
|
||||
|
||||
{#if error}<div class="rounded-xl border border-rose-400/30 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div>{/if}
|
||||
{#if message}<div class="rounded-xl border border-emerald-400/30 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">{message}</div>{/if}
|
||||
{#if revealedSecret}
|
||||
<section class="rounded-2xl border border-amber-300/30 bg-amber-400/10 p-4">
|
||||
<h3 class="font-semibold text-amber-100">New API key secret</h3>
|
||||
<p class="mt-1 text-sm text-amber-50/80">Copy this value now. It is shown only once.</p>
|
||||
<code class="mt-3 block overflow-x-auto rounded-lg bg-slate-950/80 p-3 text-sm text-amber-100">{revealedSecret}</code>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-3">
|
||||
<form class="rounded-2xl border border-white/10 bg-slate-900/70 p-4" onsubmit={(event) => { event.preventDefault(); void createTenant(); }}>
|
||||
<h3 class="font-semibold text-white">New tenant</h3>
|
||||
<label class="mt-3 block text-sm text-slate-300" for="tenant-name">Tenant name</label>
|
||||
<input id="tenant-name" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={tenantName} required />
|
||||
<button class="mt-3 rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm text-cyan-100 disabled:opacity-50" disabled={busy}>Create tenant</button>
|
||||
</form>
|
||||
|
||||
<form class="rounded-2xl border border-white/10 bg-slate-900/70 p-4" onsubmit={(event) => { event.preventDefault(); void createUser(); }}>
|
||||
<h3 class="font-semibold text-white">New user</h3>
|
||||
<label class="mt-3 block text-sm text-slate-300" for="user-tenant">Tenant</label>
|
||||
<select id="user-tenant" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={userTenantID} required>
|
||||
<option value="" disabled>Select a tenant</option>{#each identity.tenants as tenant}<option value={tenant.id}>{tenant.name}</option>{/each}
|
||||
</select>
|
||||
<label class="mt-3 block text-sm text-slate-300" for="user-name">Name</label><input id="user-name" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={userName} required />
|
||||
<label class="mt-3 block text-sm text-slate-300" for="user-email">Email</label><input id="user-email" type="email" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={userEmail} required />
|
||||
<button class="mt-3 rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm text-cyan-100 disabled:opacity-50" disabled={busy || identity.tenants.length === 0}>Create user</button>
|
||||
</form>
|
||||
|
||||
<form class="rounded-2xl border border-white/10 bg-slate-900/70 p-4" onsubmit={(event) => { event.preventDefault(); void createKey(); }}>
|
||||
<h3 class="font-semibold text-white">New managed key</h3>
|
||||
<label class="mt-3 block text-sm text-slate-300" for="key-tenant">Tenant</label>
|
||||
<select id="key-tenant" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={keyTenantID} onchange={() => { keyUserID = ''; }} required>
|
||||
<option value="" disabled>Select a tenant</option>{#each identity.tenants as tenant}<option value={tenant.id}>{tenant.name}</option>{/each}
|
||||
</select>
|
||||
<label class="mt-3 block text-sm text-slate-300" for="key-user">User (optional)</label>
|
||||
<select id="key-user" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={keyUserID}><option value="">Unassigned</option>{#each usersForTenant(keyTenantID) as user}<option value={user.id}>{user.name} ({user.email})</option>{/each}</select>
|
||||
<label class="mt-3 block text-sm text-slate-300" for="key-description">Description</label><input id="key-description" class="mt-1 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-white" bind:value={keyDescription} placeholder="e.g. production deployer" required />
|
||||
<button class="mt-3 rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm text-cyan-100 disabled:opacity-50" disabled={busy || identity.tenants.length === 0}>Create key</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-2xl border border-dashed border-white/10 py-12 text-center text-slate-400">Loading identity data…</div>
|
||||
{:else}
|
||||
<section class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<h3 class="font-semibold text-white">Tenants ({identity.tenants.length})</h3>
|
||||
<p class="mt-1 text-sm text-slate-400">Adopting legacy data is explicit: it assigns every unassigned pre-tenancy record to one tenant.</p>
|
||||
<div class="mt-3 overflow-x-auto"><table class="min-w-full text-sm"><thead class="text-left text-xs uppercase tracking-wider text-slate-500"><tr><th class="pb-2 pr-4">Name</th><th class="pb-2 pr-4">Users</th><th class="pb-2 pr-4">Created</th><th class="pb-2"></th></tr></thead><tbody class="divide-y divide-white/5">{#each identity.tenants as tenant}<tr><td class="py-3 pr-4 font-medium text-white">{tenant.name}<div class="mt-1 font-mono text-xs text-slate-500">{tenant.id}</div></td><td class="py-3 pr-4 text-slate-300">{usersForTenant(tenant.id).length}</td><td class="py-3 pr-4 text-slate-400">{formatDate(tenant.created_at)}</td><td class="py-3 text-right"><button class="rounded-lg border border-amber-300/30 bg-amber-400/10 px-3 py-1.5 text-xs text-amber-100 hover:bg-amber-400/20 disabled:opacity-50" onclick={() => void adoptLegacy(tenant)} disabled={busy}>Adopt legacy data</button></td></tr>{:else}<tr><td class="py-5 text-slate-500" colspan="4">No tenants yet.</td></tr>{/each}</tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
|
||||
<h3 class="font-semibold text-white">API keys ({identity.keys.length})</h3>
|
||||
<p class="mt-1 text-sm text-slate-400">Configured keys can be reassigned here; their secret values are never displayed.</p>
|
||||
<div class="mt-3 overflow-x-auto"><table class="min-w-[860px] w-full text-sm"><thead class="text-left text-xs uppercase tracking-wider text-slate-500"><tr><th class="pb-2 pr-4">Key</th><th class="pb-2 pr-4">Tenant</th><th class="pb-2 pr-4">User</th><th class="pb-2 pr-4">Description</th><th class="pb-2 pr-4">Status</th><th class="pb-2">Created</th></tr></thead><tbody class="divide-y divide-white/5">{#each identity.keys as key}<tr><td class="py-3 pr-4"><code class="text-xs text-cyan-100">{key.id}</code><div class="mt-1 text-xs text-slate-500">{key.source}</div></td><td class="py-3 pr-4"><select aria-label={`Tenant for ${key.id}`} class="w-full rounded-lg border border-white/10 bg-slate-950 px-2 py-1.5 text-slate-200" value={key.tenant_id} onchange={(event) => void updateKey(key, 'tenant', event.currentTarget.value)} disabled={busy}>{#each identity.tenants as tenant}<option value={tenant.id}>{tenant.name}</option>{/each}</select></td><td class="py-3 pr-4"><select aria-label={`User for ${key.id}`} class="w-full rounded-lg border border-white/10 bg-slate-950 px-2 py-1.5 text-slate-200" value={key.user_id ?? ''} onchange={(event) => void updateKey(key, 'user', event.currentTarget.value)} disabled={busy}><option value="">Unassigned</option>{#each usersForTenant(key.tenant_id) as user}<option value={user.id}>{user.name}</option>{/each}</select></td><td class="py-3 pr-4"><input aria-label={`Description for ${key.id}`} class="w-full rounded-lg border border-white/10 bg-slate-950 px-2 py-1.5 text-slate-200" value={key.description} onchange={(event) => void updateKey(key, 'description', event.currentTarget.value)} disabled={busy} /></td><td class="py-3 pr-4"><label class="flex items-center gap-2 text-slate-300"><input type="checkbox" class="accent-cyan-400" checked={key.enabled} onchange={(event) => void updateKey(key, 'enabled', event.currentTarget.checked)} disabled={busy} />{#if key.enabled}Enabled{:else}<BooleanStatusBadge value={key.enabled} falseLabel="Disabled" />{/if}</label></td><td class="py-3 text-slate-400">{formatDate(key.created_at)}</td></tr>{:else}<tr><td class="py-5 text-slate-500" colspan="6">No keys available.</td></tr>{/each}</tbody></table></div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
value: boolean;
|
||||
falseLabel: 'Disabled' | 'Inactive';
|
||||
};
|
||||
|
||||
let { value, falseLabel }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if !value}
|
||||
<span class="inline-flex rounded-full bg-slate-700 px-2 py-0.5 text-xs font-medium text-slate-200">
|
||||
{falseLabel}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -10,7 +10,10 @@
|
||||
import ProjectsPage from '../projects/ProjectsPage.svelte';
|
||||
import SkillsPage from '../skills/SkillsPage.svelte';
|
||||
import ThoughtsPage from '../thoughts/ThoughtsPage.svelte';
|
||||
import IdentityPage from '../identity/IdentityPage.svelte';
|
||||
import AppSidebar from './AppSidebar.svelte';
|
||||
import { fromStore } from 'svelte/store';
|
||||
import { selectedTenantID } from '../../tenantScope';
|
||||
|
||||
const {
|
||||
currentPage,
|
||||
@@ -29,14 +32,19 @@
|
||||
onnavigate: (page: ShellPage) => void;
|
||||
onrefresh: () => void;
|
||||
} = $props();
|
||||
|
||||
const tenantScope = fromStore(selectedTenantID);
|
||||
</script>
|
||||
|
||||
<div class="grid min-h-screen lg:grid-cols-[17rem_1fr]">
|
||||
<AppSidebar {currentPage} {onnavigate} {onlogout} />
|
||||
|
||||
<main class="px-4 py-6 sm:px-6 lg:px-8">
|
||||
{#key tenantScope.current}
|
||||
{#if currentPage === 'dashboard'}
|
||||
<DashboardPage {data} {loading} {error} {onrefresh} />
|
||||
{:else if currentPage === 'identity'}
|
||||
<IdentityPage />
|
||||
{:else if currentPage === 'projects'}
|
||||
<ProjectsPage />
|
||||
{:else if currentPage === 'thoughts'}
|
||||
@@ -56,5 +64,6 @@
|
||||
{:else if currentPage === 'maintenance'}
|
||||
<MaintenancePage />
|
||||
{/if}
|
||||
{/key}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { NavItem, ShellPage } from '../../types';
|
||||
import { api } from '../../api';
|
||||
import { selectedTenantID, setSelectedTenantID } from '../../tenantScope';
|
||||
|
||||
let tenants = $state<{ id: string; name: string }[]>([]);
|
||||
let tenantError = $state('');
|
||||
let tenantLoading = $state(false);
|
||||
|
||||
const {
|
||||
currentPage,
|
||||
@@ -13,6 +20,7 @@
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', description: 'System overview and status.' },
|
||||
{ id: 'identity', label: 'Identity', description: 'Tenants, users, and API keys.' },
|
||||
{ id: 'projects', label: 'Projects', description: 'Browse and manage projects.' },
|
||||
{ id: 'thoughts', label: 'Thoughts', description: 'Search and inspect thoughts.' },
|
||||
{ id: 'learnings', label: 'Learnings', description: 'Curated insights and outcomes.' },
|
||||
@@ -23,6 +31,25 @@
|
||||
{ id: 'files', label: 'Files', description: 'Stored file inventory.' },
|
||||
{ id: 'maintenance', label: 'Maintenance', description: 'Task state and upkeep actions.' }
|
||||
];
|
||||
|
||||
async function loadTenants(): Promise<void> {
|
||||
tenantLoading = true;
|
||||
tenantError = '';
|
||||
try {
|
||||
tenants = (await api.identity.get()).tenants;
|
||||
if ($selectedTenantID && !tenants.some((tenant) => tenant.id === $selectedTenantID)) {
|
||||
setSelectedTenantID('');
|
||||
}
|
||||
} catch (cause) {
|
||||
tenantError = cause instanceof Error ? cause.message : 'Failed to load tenants.';
|
||||
} finally {
|
||||
tenantLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadTenants();
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside class="border-r border-white/10 bg-slate-900/90 p-6">
|
||||
@@ -32,6 +59,26 @@
|
||||
<p class="mt-2 text-sm text-slate-400">Memory server control panel.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-white/10 bg-slate-950/40 p-3">
|
||||
<label class="block text-xs font-semibold uppercase tracking-wider text-slate-400" for="tenant-selector">Tenant scope</label>
|
||||
<select
|
||||
id="tenant-selector"
|
||||
class="mt-2 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 disabled:opacity-50"
|
||||
value={$selectedTenantID}
|
||||
onchange={(event) => setSelectedTenantID(event.currentTarget.value)}
|
||||
disabled={tenantLoading}
|
||||
>
|
||||
<option value="">Default key tenant</option>
|
||||
{#each tenants as tenant}
|
||||
<option value={tenant.id}>{tenant.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="mt-2 text-xs text-slate-500">Applies to tenant data in this admin session.</p>
|
||||
{#if tenantError}
|
||||
<p class="mt-2 text-xs text-rose-300">{tenantError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<nav class="mt-8 space-y-1">
|
||||
{#each navItems as item}
|
||||
<button
|
||||
|
||||
@@ -576,7 +576,6 @@
|
||||
adapter={projectBoxerAdapter}
|
||||
value={state.values?.project_id ?? null}
|
||||
clearable
|
||||
searchable
|
||||
onChange={(v) => state.setState('values', { ...state.values, project_id: v || undefined })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import './app.css';
|
||||
import App from './App.svelte';
|
||||
import { mount } from 'svelte';
|
||||
import { installTenantScopedFetch } from './tenantScope';
|
||||
|
||||
installTenantScopedFetch();
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { get, writable } from 'svelte/store';
|
||||
|
||||
const STORAGE_KEY = 'amcs.admin.selected-tenant-id';
|
||||
const TENANT_HEADER = 'X-AMCS-Tenant-ID';
|
||||
let tenantScopedFetchInstalled = false;
|
||||
|
||||
function initialTenantID(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return window.localStorage.getItem(STORAGE_KEY)?.trim() ?? '';
|
||||
}
|
||||
|
||||
export const selectedTenantID = writable<string>(initialTenantID());
|
||||
|
||||
export function setSelectedTenantID(tenantID: string): void {
|
||||
const normalized = tenantID.trim();
|
||||
selectedTenantID.set(normalized);
|
||||
|
||||
if (typeof window === 'undefined') return;
|
||||
if (normalized) {
|
||||
window.localStorage.setItem(STORAGE_KEY, normalized);
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function tenantScopeHeaders(): Record<string, string> {
|
||||
const tenantID = currentTenantID();
|
||||
return tenantID ? { [TENANT_HEADER]: tenantID } : {};
|
||||
}
|
||||
|
||||
export function currentTenantID(): string {
|
||||
return get(selectedTenantID).trim();
|
||||
}
|
||||
|
||||
function shouldScopeRequest(input: RequestInfo | URL): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
const rawURL = input instanceof Request ? input.url : input.toString();
|
||||
const url = new URL(rawURL, window.location.origin);
|
||||
return url.origin === window.location.origin && (url.pathname.startsWith('/api/rs') || url.pathname.startsWith('/api/admin'));
|
||||
}
|
||||
|
||||
// Gridler and Former perform their own fetches. Scope those requests here so
|
||||
// their ResolveSpec calls use the same tenant as the rest of the admin UI.
|
||||
export function installTenantScopedFetch(): void {
|
||||
if (typeof window === 'undefined' || tenantScopedFetchInstalled) return;
|
||||
|
||||
const baseFetch = window.fetch.bind(window);
|
||||
|
||||
async function scopedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const headersToAdd = tenantScopeHeaders();
|
||||
if (!Object.keys(headersToAdd).length || !shouldScopeRequest(input)) {
|
||||
return baseFetch(input, init);
|
||||
}
|
||||
|
||||
const headers = new Headers(input instanceof Request ? input.headers : undefined);
|
||||
if (init?.headers) {
|
||||
new Headers(init.headers).forEach((value, name) => headers.set(name, value));
|
||||
}
|
||||
Object.entries(headersToAdd).forEach(([name, value]) => headers.set(name, value));
|
||||
return baseFetch(input, { ...init, headers });
|
||||
}
|
||||
|
||||
window.fetch = scopedFetch;
|
||||
tenantScopedFetchInstalled = true;
|
||||
}
|
||||
+31
-1
@@ -66,7 +66,7 @@ export type NavItem = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'learnings' | 'plans' | 'skills' | 'guardrails' | 'personas' | 'files' | 'maintenance';
|
||||
export type ShellPage = 'dashboard' | 'identity' | 'projects' | 'thoughts' | 'learnings' | 'plans' | 'skills' | 'guardrails' | 'personas' | 'files' | 'maintenance';
|
||||
|
||||
export type Project = {
|
||||
id: string;
|
||||
@@ -295,3 +295,33 @@ export type MetadataRetryResult = {
|
||||
failed: number;
|
||||
dry_run: boolean;
|
||||
};
|
||||
|
||||
export type Tenant = {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type TenantUser = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type IdentityKey = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
user_id?: string;
|
||||
description: string;
|
||||
source: 'configured' | 'managed';
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type IdentityData = {
|
||||
tenants: Tenant[];
|
||||
users: TenantUser[];
|
||||
keys: IdentityKey[];
|
||||
};
|
||||
|
||||
+3
-3
@@ -508,8 +508,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
singleResult := reflect.New(modelType).Interface()
|
||||
pkName := reflection.GetPrimaryKeyName(singleResult)
|
||||
|
||||
query = query.Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
|
||||
if err := query.Scan(ctx, singleResult); err != nil {
|
||||
query = query.Model(singleResult).Where(fmt.Sprintf("%s = ?", common.QuoteIdent(pkName)), targetID)
|
||||
if err := query.ScanModel(ctx); err != nil {
|
||||
logger.Error("Error querying record: %v", err)
|
||||
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err)
|
||||
return
|
||||
@@ -518,7 +518,7 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
||||
} else {
|
||||
logger.Debug("Querying multiple records")
|
||||
// Use the modelPtr already created and set on the query
|
||||
if err := query.Scan(ctx, modelPtr); err != nil {
|
||||
if err := query.ScanModel(ctx); err != nil {
|
||||
logger.Error("Error querying records: %v", err)
|
||||
h.sendError(w, http.StatusInternalServerError, "query_error", "Error executing query", err)
|
||||
return
|
||||
|
||||
Vendored
+1
-1
@@ -4,7 +4,7 @@ git.warky.dev/wdevs/relspecgo/pkg/sqltypes
|
||||
# github.com/beorn7/perks v1.0.1
|
||||
## explicit; go 1.11
|
||||
github.com/beorn7/perks/quantile
|
||||
# github.com/bitechdev/ResolveSpec v1.1.26
|
||||
# github.com/bitechdev/ResolveSpec v1.1.27
|
||||
## explicit; go 1.25.7
|
||||
github.com/bitechdev/ResolveSpec/pkg/cache
|
||||
github.com/bitechdev/ResolveSpec/pkg/common
|
||||
|
||||
Reference in New Issue
Block a user