feat(ui): add identity management for tenants and users
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:
2026-07-20 22:50:55 +02:00
parent 3d4e6d0939
commit 196b543bee
64 changed files with 3363 additions and 615 deletions
+2
View File
@@ -23,6 +23,8 @@ auth:
keys:
- id: "local-client"
value: "replace-me"
tenant_id: "local"
superadmin: true
description: "main local client key"
oauth:
clients:
+2
View File
@@ -21,6 +21,8 @@ auth:
keys:
- id: "local-client"
value: "replace-me"
tenant_id: "local"
superadmin: true
description: "main local client key"
oauth:
clients:
+2
View File
@@ -21,6 +21,8 @@ auth:
keys:
- id: "local-client"
value: "replace-me"
tenant_id: "local"
superadmin: true
description: "main local client key"
oauth:
clients:
+19
View File
@@ -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).
+3
View File
@@ -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.
+3
View File
@@ -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.
+20 -20
View File
@@ -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:
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+364
View File
@@ -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"})
}
+11
View File
@@ -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)
+7 -1
View File
@@ -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{}},
+2 -2
View File
@@ -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
View File
@@ -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[:])
}
+20
View File
@@ -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 {
+5 -1
View File
@@ -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) {
+2
View File
@@ -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
+2 -1
View File
@@ -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
+3 -3
View File
@@ -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)))
-1
View File
@@ -475,4 +475,3 @@ func canonicalPlanPair(a, b int64) (int64, int64) {
}
return b, a
}
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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)))
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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
);
+53
View File
@@ -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 $$;
+17 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+33
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
+435 -390
View File
File diff suppressed because it is too large Load Diff
+84 -3
View File
@@ -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>
+47
View File
@@ -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>
+3
View File
@@ -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')!
+66
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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