14 Commits

Author SHA1 Message Date
warkanum d7c0205c50 feat(auth): add superadmin support for OAuth clients
CI / build-and-test (push) Successful in 2m46s
* Introduce Superadmin field in OAuthClient configuration
* Implement IsSuperadmin method in OAuthRegistry
* Update identityAdmin to check superadmin status via OAuthRegistry
* Add tests for OAuthRegistry superadmin functionality
2026-07-20 23:19:51 +02:00
warkanum 196b543bee 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
2026-07-20 22:50:55 +02:00
Hein 3d4e6d0939 feat(sqltypes): add nullable SQL types with automatic casting
CI / build-and-test (push) Successful in 1m54s
Release / release (push) Successful in 3m12s
* Introduced SqlNull type for nullable values with auto-casting.
* Implemented JSON, YAML, and XML marshaling/unmarshaling.
* Added specific types for common SQL types (e.g., SqlInt64, SqlString).
* Included utility functions for creating nullable types.
* Added SqlTimeStamp, SqlDate, and SqlTime types with custom formatting.
2026-07-15 12:55:15 +02:00
Hein f94fddddb1 feat(security): add support for query modes in TOTP provider
CI / build-and-test (push) Successful in 1m33s
* Introduced QueryMode to select between stored procedure and direct SQL execution.
* Implemented dbCapability to check for stored procedure existence.
* Added methods to DatabaseTwoFactorProvider for configuring table names and query modes.
* Created direct SQL implementations for TOTP operations to handle cases where stored procedures are not available.
* Updated existing TOTP methods to utilize the new query mode logic.
2026-07-15 12:51:09 +02:00
warkanum 631bb64109 Merge pull request 'Design and implement per-user tenancy' (#40) from issue-10-per-user-tenancy into main
CI / build-and-test (push) Successful in 1m40s
Reviewed-on: #40
2026-07-15 10:48:24 +00:00
Hein 4f8f2f4190 Merge branch 'main' of git.warky.dev:wdevs/amcs into issue-10-per-user-tenancy
CI / build-and-test (push) Successful in 1m39s
CI / build-and-test (pull_request) Successful in 2m3s
2026-07-15 12:48:10 +02:00
warkanum 1689167a7b Merge pull request 'AMCS: add duplicate audit report tool' (#38) from issue-25-duplicate-audit-cleanup-tools into main
CI / build-and-test (push) Successful in 1m23s
Reviewed-on: #38
2026-07-15 04:35:57 +00:00
warkanum 5cd81f325f Merge pull request 'Add webhook ingestion endpoint for external sources' (#39) from issue-8-webhook-ingestion into main
CI / build-and-test (push) Successful in 1m10s
Reviewed-on: #39
2026-07-15 04:35:20 +00:00
warkanum cd010fc7a1 docs: plan per-user tenancy model
CI / build-and-test (push) Successful in 2m30s
CI / build-and-test (pull_request) Successful in 2m35s
2026-07-15 05:22:22 +02:00
warkanum e3a4a3c5c7 fix: build UI assets in CI
CI / build-and-test (pull_request) Successful in 1m20s
CI / build-and-test (push) Successful in 1m23s
2026-07-15 05:14:09 +02:00
warkanum 4b3f0b1b55 feat: add per-user tenant scoping
CI / build-and-test (push) Failing after 1m53s
CI / build-and-test (pull_request) Failing after 1m43s
2026-07-15 04:27:44 +02:00
warkanum e459775740 feat: add webhook thought ingestion
CI / build-and-test (pull_request) Successful in 3m33s
CI / build-and-test (push) Successful in 3m37s
2026-07-15 04:27:09 +02:00
warkanum 8db2141d45 ci: build ui before go tests
CI / build-and-test (push) Successful in 7m10s
CI / build-and-test (pull_request) Successful in 7m13s
2026-07-14 16:32:00 +02:00
warkanum 3198600031 feat(tools): add duplicate audit report
CI / build-and-test (push) Failing after 1m24s
CI / build-and-test (pull_request) Failing after 59s
2026-07-14 16:28:06 +02:00
118 changed files with 8471 additions and 1105 deletions
+36
View File
@@ -18,6 +18,14 @@ jobs:
with:
go-version: '1.26'
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Enable pnpm
run: corepack enable
- name: Cache Go modules
uses: actions/cache@v4
with:
@@ -31,9 +39,37 @@ jobs:
- name: Download dependencies
run: go mod download
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install pnpm
run: npm install -g pnpm
- name: Build UI
run: |
cd ui
pnpm install --frozen-lockfile
pnpm run build
- name: Tidy modules
run: go mod tidy
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install pnpm
run: npm install -g pnpm
- name: Build UI
run: |
cd ui
pnpm install --frozen-lockfile
pnpm run build
- name: Run tests
run: go test ./...
+3
View File
@@ -23,12 +23,15 @@ auth:
keys:
- id: "local-client"
value: "replace-me"
tenant_id: "local"
superadmin: true
description: "main local client key"
oauth:
clients:
- id: "oauth-client"
client_id: ""
client_secret: ""
superadmin: false
description: "optional OAuth client credentials"
database:
+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.
+161
View File
@@ -0,0 +1,161 @@
# Per-user tenancy implementation plan
## Scope and current state
Gitea issue #10 asks AMCS to isolate memories by user, tenant, or workspace. The current branch already carries the first implementation pass, so this plan records the intended model and the remaining hardening work another worker should verify or finish.
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_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_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.
## Tenant/user identity source
Use the authenticated principal id as the tenant boundary:
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.
## Required schema/model changes
Source-of-truth DBML changes:
- Add nullable `tenant_id text` to all user-owned tables:
- `projects`
- `thoughts`
- `stored_files`
- `learnings`
- `plans`
- `chat_histories`
- Add tenant indexes:
- 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.
## Query scoping strategy
All request-facing store methods that touch tenant-owned rows must include tenant predicates whenever `tenancy.KeyFromContext(ctx)` returns a key.
Rules:
- 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.
Already-scoped paths to keep:
- `internal/store/projects.go`: create/get/list/touch projects.
- `internal/store/thoughts.go`: create/list/get/update/delete/archive/search/stats/embedding repair paths.
- `internal/store/files.go`: insert/get/list stored files.
Paths needing audit/hardening:
- `internal/store/learnings.go`: `CreateLearning`, `GetLearning`, and `ListLearnings` should populate/filter by tenant.
- `internal/store/plans.go`: `CreatePlan`, `GetPlan`, `GetPlanDetail`, `UpdatePlan`, `DeletePlan`, `ListPlans`, dependency/related-plan operations, and plan skill/guardrail joins should scope to tenant-owned plans.
- `internal/store/chat_histories.go`: chat history create/get/list/update paths should populate/filter by tenant.
- `internal/store/thought_learning_links.go`: links traverse tenant-owned thoughts/learnings; queries should join and scope both sides or at least the tenant-owned root.
- `internal/store/skills.go` and `internal/store/project_personas.go`: project-scoped joins should verify the project is visible to the current tenant before returning linked global catalog records.
- ResolveSpec/admin CRUD endpoints exposing generated models must not bypass tenant-aware store methods. If they use direct generic model access, add middleware-level filter injection or disable tenant-owned tables from generic admin writes.
## Migration and backfill
Migration approach for existing single-tenant installs:
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_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_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_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.
## Affected endpoints/services/UI surfaces
Backend/MCP tools:
- Project tools: create/get/list/set active project.
- Thought tools: capture, retrieve, update, delete/archive, semantic search, text search, stats, metadata and embedding repair queues.
- File tools and binary file upload/download APIs.
- Learning tools and thought-learning link tools.
- Plan/task tools including dependencies, related plans, skills, and guardrails.
- Chat history/session persistence tools.
- Persona/skill/guardrail project-association tools.
HTTP/API boundaries:
- MCP SSE and streamable HTTP handlers must run behind auth middleware when auth is configured.
- Any non-MCP REST endpoints under the admin/API server must either use tenant-aware stores or explicitly be internal/admin-only.
- ResolveSpec admin CRUD views need tenant filter injection or table-level access restrictions for tenant-owned models.
UI:
- Project selector/list should naturally show tenant-scoped projects.
- 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
Minimum test matrix:
1. Auth middleware propagates tenant key for API-key header auth.
2. Auth middleware propagates tenant key for bearer token via OAuth token store.
3. Auth middleware propagates tenant key for HTTP Basic OAuth client credentials.
4. Tenant A and tenant B can create projects with the same name; each only lists its own project.
5. Tenant A cannot get/update/delete/archive Tenant B's thought by guid or numeric id.
6. Semantic and text search return only same-tenant thoughts, including project-filtered searches.
7. Stored file get/list/download is scoped; a file id from another tenant returns not found.
8. Learning create/list/get is scoped, including links to thoughts.
9. Plan create/list/get/update/delete and dependency/related-plan operations are scoped.
10. Project skill/persona/guardrail association reads verify the project belongs to the tenant.
11. Background metadata/embedding retry queues do not cross tenants unless intentionally internal.
12. Migration test covers legacy null-tenant rows and backfill to default tenant.
13. ResolveSpec/admin API tests prove tenant-owned resources are filtered or unavailable without admin override.
## Assumptions and blockers
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_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:
- Decide whether legacy rows should be backfilled automatically to a configured default tenant during migration or left null until an operator runs an explicit backfill.
- Decide whether ResolveSpec admin endpoints are trusted admin-only or must enforce tenant predicates like MCP tools.
- Decide whether future OAuth subjects should distinguish user id from client id; the current implementation only has client/key id available.
## Implementation order
1. Finish tenant scoping audits for learnings, plans, chat histories, thought-learning links, and project catalog joins.
2. Add tests proving cross-tenant invisibility for each store/tool domain before widening coverage. Yes, tests first; future us has enough enemies.
3. Add config/backfill migration behavior and document operator steps.
4. Lock down or filter ResolveSpec/admin tenant-owned model access.
5. Run `make test` and `make build`; include exact results in the issue/PR comment.
+2 -2
View File
@@ -3,7 +3,8 @@ module git.warky.dev/wdevs/amcs
go 1.26.1
require (
github.com/bitechdev/ResolveSpec v1.1.24
git.warky.dev/wdevs/relspecgo v1.0.62
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
@@ -46,7 +47,6 @@ require (
github.com/prometheus/procfs v0.20.1 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/redis/go-redis/v9 v9.19.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
+6 -4
View File
@@ -2,6 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
git.warky.dev/wdevs/relspecgo v1.0.62 h1:byBe2IlcwQRKsV5qXHfGITtfvGZewHlNstP+O8BGnns=
git.warky.dev/wdevs/relspecgo v1.0.62/go.mod h1:JpZBvui9dYc/QcD5TZ1wKab+4bUvPw5/rOLOA/h2F6A=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.1/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo=
@@ -34,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.24 h1:+Ku3jE8ZSQ2c6IdVyqYp9CdCh4wSasCd1yLDF8GXy5U=
github.com/bitechdev/ResolveSpec v1.1.24/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=
@@ -452,8 +454,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+372
View File
@@ -0,0 +1,372 @@
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
oauthRegistry *auth.OAuthRegistry
logger *slog.Logger
}
func newIdentityAdmin(pool *pgxpool.Pool, keyring *auth.Keyring, oauthRegistry *auth.OAuthRegistry, logger *slog.Logger) *identityAdmin {
return &identityAdmin{pool: pool, keyring: keyring, oauthRegistry: oauthRegistry, logger: logger}
}
func (a *identityAdmin) isSuperadmin(keyID string) bool {
if a.keyring != nil && a.keyring.IsSuperadmin(keyID) {
return true
}
return a.oauthRegistry != nil && a.oauthRegistry.IsSuperadmin(keyID)
}
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.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"})
}
+12
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, oauthRegistry, logger)
toolSet := mcpserver.ToolSet{
Capture: tools.NewCaptureTool(db, embeddings, cfg.Capture, activeProjects, enrichmentRetryer, backfillTool),
@@ -202,6 +211,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
Update: tools.NewUpdateTool(db, embeddings, metadata, cfg.Capture, logger),
Delete: tools.NewDeleteTool(db),
Archive: tools.NewArchiveTool(db),
DuplicateAudit: tools.NewDuplicateAuditTool(db, cfg.Search, activeProjects),
Projects: tools.NewProjectsTool(db, activeProjects),
Version: tools.NewVersionTool(cfg.MCP.ServerName, info),
Learnings: tools.NewLearningsTool(db, activeProjects, cfg.Search),
@@ -245,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,9 @@ 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{}},
{schema: "public", entity: "tool_annotations", model: generatedmodels.ModelPublicToolAnnotations{}},
+112 -1
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 {
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 {
+14 -5
View File
@@ -11,6 +11,7 @@ import (
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/observability"
"git.warky.dev/wdevs/amcs/internal/requestip"
"git.warky.dev/wdevs/amcs/internal/tenancy"
)
type contextKey string
@@ -50,6 +51,14 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
)
}
}
withTenant := func(ctx context.Context, keyID string) context.Context {
ctx = context.WithValue(ctx, keyIDContextKey, 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) {
remoteAddr := requestip.FromRequest(r)
@@ -63,7 +72,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
return
}
recordAccess(r, keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
return
}
}
@@ -73,14 +82,14 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
if tokenStore != nil {
if keyID, ok := tokenStore.Lookup(bearer); ok {
recordAccess(r, keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
return
}
}
if keyring != nil {
if keyID, ok := keyring.Lookup(bearer); ok {
recordAccess(r, keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
return
}
}
@@ -103,7 +112,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
return
}
recordAccess(r, keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
return
}
@@ -117,7 +126,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
return
}
recordAccess(r, keyID)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
return
}
}
+15
View File
@@ -31,3 +31,18 @@ func (o *OAuthRegistry) Lookup(clientID string, clientSecret string) (string, bo
}
return "", false
}
// IsSuperadmin reports whether keyID (as returned by Lookup) belongs to an
// OAuth client configured with superadmin: true.
func (o *OAuthRegistry) IsSuperadmin(keyID string) bool {
for _, client := range o.clients {
id := client.ID
if id == "" {
id = client.ClientID
}
if id == keyID {
return client.Superadmin
}
}
return false
}
+20
View File
@@ -32,6 +32,26 @@ func TestNewOAuthRegistryAndLookup(t *testing.T) {
}
}
func TestOAuthRegistryIsSuperadmin(t *testing.T) {
registry, err := NewOAuthRegistry([]config.OAuthClient{
{ID: "oauth-admin", ClientID: "admin-id", ClientSecret: "admin-secret", Superadmin: true},
{ID: "oauth-client", ClientID: "client-id", ClientSecret: "client-secret"},
})
if err != nil {
t.Fatalf("NewOAuthRegistry() error = %v", err)
}
if !registry.IsSuperadmin("oauth-admin") {
t.Fatal("IsSuperadmin(oauth-admin) = false, want true")
}
if registry.IsSuperadmin("oauth-client") {
t.Fatal("IsSuperadmin(oauth-client) = true, want false")
}
if registry.IsSuperadmin("unknown") {
t.Fatal("IsSuperadmin(unknown) = true, want false")
}
}
func TestMiddlewareAllowsOAuthBasicAuthAndSetsContext(t *testing.T) {
oauthRegistry, err := NewOAuthRegistry([]config.OAuthClient{{
ID: "oauth-client",
+44
View File
@@ -0,0 +1,44 @@
package auth
import (
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/tenancy"
)
func TestMiddlewareAddsTenantKeyToContext(t *testing.T) {
keyring, err := NewKeyring([]config.APIKey{{ID: "user-a", Value: "secret-a"}})
if err != nil {
t.Fatalf("NewKeyring error = %v", err)
}
var gotKeyID, gotTenant string
handler := Middleware(config.AuthConfig{}, keyring, nil, nil, nil, slog.Default())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ok bool
gotKeyID, ok = KeyIDFromContext(r.Context())
if !ok {
t.Fatal("KeyIDFromContext ok = false")
}
gotTenant, ok = tenancy.KeyFromContext(r.Context())
if !ok {
t.Fatal("tenancy.KeyFromContext ok = false")
}
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodPost, "/mcp", nil)
req.Header.Set("x-brain-key", "secret-a")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusNoContent)
}
if gotKeyID != "user-a" || gotTenant != "user-a" {
t.Fatalf("keyID=%q tenant=%q, want user-a/user-a", gotKeyID, gotTenant)
}
}
+3
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"`
}
@@ -64,6 +66,7 @@ type OAuthClient struct {
ID string `yaml:"id"`
ClientID string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret"`
Superadmin bool `yaml:"superadmin"`
Description string `yaml:"description"`
}
@@ -3,21 +3,23 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicAgentGuardrails struct {
bun.BaseModel `bun:"table:public.agent_guardrails,alias:agent_guardrails"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
Severity resolvespec_common.SqlString `bun:"severity,type:text,default:'medium',notnull," json:"severity"`
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
Content sql_types.SqlString `bun:"content,type:text,notnull," json:"content"`
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"`
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
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
@@ -3,22 +3,24 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicAgentParts struct {
bun.BaseModel `bun:"table:public.agent_parts,alias:agent_parts"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
Content resolvespec_common.SqlString `bun:"content,type:text,default:'',notnull," json:"content"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
PartType resolvespec_common.SqlString `bun:"part_type,type:text,notnull," json:"part_type"`
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"`
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
Content sql_types.SqlString `bun:"content,type:text,default:'',notnull," json:"content"`
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"`
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
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
}
@@ -3,13 +3,13 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicAgentPersonaGuardrails struct {
bun.BaseModel `bun:"table:public.agent_persona_guardrails,alias:agent_persona_guardrails"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull," json:"guardrail_id"`
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
@@ -3,13 +3,13 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicAgentPersonaParts struct {
bun.BaseModel `bun:"table:public.agent_persona_parts,alias:agent_persona_parts"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
PartID int64 `bun:"part_id,type:bigint,notnull," json:"part_id"`
PartOrder int32 `bun:"part_order,type:int,default:0,notnull," json:"part_order"`
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
@@ -3,13 +3,13 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicAgentPersonaSkills struct {
bun.BaseModel `bun:"table:public.agent_persona_skills,alias:agent_persona_skills"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
Override bool `bun:"override,type:boolean,default:false,notnull," json:"override"`
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
SkillID int64 `bun:"skill_id,type:bigint,notnull," json:"skill_id"`
@@ -3,13 +3,13 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicAgentPersonaTraits struct {
bun.BaseModel `bun:"table:public.agent_persona_traits,alias:agent_persona_traits"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
TraitID int64 `bun:"trait_id,type:bigint,notnull," json:"trait_id"`
RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas
@@ -3,24 +3,26 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicAgentPersonas struct {
bun.BaseModel `bun:"table:public.agent_personas,alias:agent_personas"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CompiledAt resolvespec_common.SqlTimeStamp `bun:"compiled_at,type:timestamptz,nullzero," json:"compiled_at"`
CompiledDetail resolvespec_common.SqlString `bun:"compiled_detail,type:text,default:'',notnull," json:"compiled_detail"`
CompiledSummary resolvespec_common.SqlString `bun:"compiled_summary,type:text,default:'',notnull," json:"compiled_summary"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
Detail resolvespec_common.SqlString `bun:"detail,type:text,default:'',notnull," json:"detail"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"`
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CompiledAt sql_types.SqlTimeStamp `bun:"compiled_at,type:timestamptz,nullzero," json:"compiled_at"`
CompiledDetail sql_types.SqlString `bun:"compiled_detail,type:text,default:'',notnull," json:"compiled_detail"`
CompiledSummary sql_types.SqlString `bun:"compiled_summary,type:text,default:'',notnull," json:"compiled_summary"`
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"`
Detail sql_types.SqlString `bun:"detail,type:text,default:'',notnull," json:"detail"`
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
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,24 +3,27 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicAgentSkills struct {
bun.BaseModel `bun:"table:public.agent_skills,alias:agent_skills"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
DomainTags resolvespec_common.SqlStringArray `bun:"domain_tags,type:text[],default:'{}',notnull," json:"domain_tags"`
FrameworkTags resolvespec_common.SqlStringArray `bun:"framework_tags,type:text[],default:'{}',notnull," json:"framework_tags"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
LanguageTags resolvespec_common.SqlStringArray `bun:"language_tags,type:text[],default:'{}',notnull," json:"language_tags"`
LibraryTags resolvespec_common.SqlStringArray `bun:"library_tags,type:text[],default:'{}',notnull," json:"library_tags"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
Content sql_types.SqlString `bun:"content,type:text,notnull," json:"content"`
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"`
DomainTags sql_types.SqlStringArray `bun:"domain_tags,type:text[],default:'{}',notnull," json:"domain_tags"`
FrameworkTags sql_types.SqlStringArray `bun:"framework_tags,type:text[],default:'{}',notnull," json:"framework_tags"`
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
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,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
@@ -3,21 +3,23 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicAgentTraits struct {
bun.BaseModel `bun:"table:public.agent_traits,alias:agent_traits"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Instruction resolvespec_common.SqlString `bun:"instruction,type:text,default:'',notnull," json:"instruction"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
TraitType resolvespec_common.SqlString `bun:"trait_type,type:text,notnull," json:"trait_type"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"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"`
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
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"
}
@@ -3,13 +3,13 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicArcStageParts struct {
bun.BaseModel `bun:"table:public.arc_stage_parts,alias:arc_stage_parts"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
PartID int64 `bun:"part_id,type:bigint,notnull," json:"part_id"`
StageID int64 `bun:"stage_id,type:bigint,notnull," json:"stage_id"`
RelPartID *ModelPublicAgentParts `bun:"rel:has-one,join:part_id=id" json:"relpartid,omitempty"` // Has one ModelPublicAgentParts
@@ -3,18 +3,18 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicArcStages struct {
bun.BaseModel `bun:"table:public.arc_stages,alias:arc_stages"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ArcID int64 `bun:"arc_id,type:bigint,notnull," json:"arc_id"`
Condition resolvespec_common.SqlString `bun:"condition,type:text,default:'',notnull," json:"condition"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
Condition sql_types.SqlString `bun:"condition,type:text,default:'',notnull," json:"condition"`
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"`
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
StageOrder int32 `bun:"stage_order,type:int,default:0,notnull," json:"stage_order"`
RelArcID *ModelPublicCharacterArcs `bun:"rel:has-one,join:arc_id=id" json:"relarcid,omitempty"` // Has one ModelPublicCharacterArcs
RelStageIDPublicArcStageParts []*ModelPublicArcStageParts `bun:"rel:has-many,join:id=stage_id" json:"relstageidpublicarcstageparts,omitempty"` // Has many ModelPublicArcStageParts
@@ -3,18 +3,20 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicCharacterArcs struct {
bun.BaseModel `bun:"table:public.character_arcs,alias:character_arcs"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
Summary resolvespec_common.SqlString `bun:"summary,type:text,default:'',notnull," json:"summary"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"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"`
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
}
@@ -3,25 +3,27 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicChatHistories struct {
bun.BaseModel `bun:"table:public.chat_histories,alias:chat_histories"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
AgentID resolvespec_common.SqlString `bun:"agent_id,type:text,nullzero," json:"agent_id"`
Channel resolvespec_common.SqlString `bun:"channel,type:text,nullzero," json:"channel"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Messages resolvespec_common.SqlJSONB `bun:"messages,type:jsonb,default:'[]',notnull," json:"messages"`
Metadata resolvespec_common.SqlJSONB `bun:"metadata,type:jsonb,default:'{}',notnull," json:"metadata"`
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
SessionID resolvespec_common.SqlString `bun:"session_id,type:text,notnull," json:"session_id"`
Summary resolvespec_common.SqlString `bun:"summary,type:text,nullzero," json:"summary"`
Title resolvespec_common.SqlString `bun:"title,type:text,nullzero," json:"title"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
AgentID sql_types.SqlString `bun:"agent_id,type:text,nullzero," json:"agent_id"`
Channel sql_types.SqlString `bun:"channel,type:text,nullzero," json:"channel"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Messages sql_types.SqlJSONB `bun:"messages,type:jsonb,default:'[]',notnull," json:"messages"`
Metadata sql_types.SqlJSONB `bun:"metadata,type:jsonb,default:'{}',notnull," json:"metadata"`
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"`
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
@@ -3,20 +3,20 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicEmbeddings struct {
bun.BaseModel `bun:"table:public.embeddings,alias:embeddings"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
Dim int32 `bun:"dim,type:int,notnull," json:"dim"`
Embedding resolvespec_common.SqlVector `bun:"embedding,type:vector,notnull," json:"embedding"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Model resolvespec_common.SqlString `bun:"model,type:text,notnull,unique:uidx_embeddings_thought_id_model," json:"model"`
Embedding sql_types.SqlVector `bun:"embedding,type:vector,notnull," json:"embedding"`
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Model sql_types.SqlString `bun:"model,type:text,notnull,unique:uidx_embeddings_thought_id_model," json:"model"`
ThoughtID int64 `bun:"thought_id,type:bigint,notnull,unique:uidx_embeddings_thought_id_model," json:"thought_id"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),nullzero," json:"updated_at"`
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),nullzero," json:"updated_at"`
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
}
@@ -3,39 +3,42 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicLearnings struct {
bun.BaseModel `bun:"table:public.learnings,alias:learnings"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ActionRequired bool `bun:"action_required,type:boolean,default:false,notnull," json:"action_required"`
Area resolvespec_common.SqlString `bun:"area,type:text,default:'other',notnull," json:"area"`
Category resolvespec_common.SqlString `bun:"category,type:text,default:'insight',notnull," json:"category"`
Confidence resolvespec_common.SqlString `bun:"confidence,type:text,default:'hypothesis',notnull," json:"confidence"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Details resolvespec_common.SqlString `bun:"details,type:text,default:'',notnull," json:"details"`
DuplicateOfLearningID resolvespec_common.SqlInt64 `bun:"duplicate_of_learning_id,type:bigint,nullzero," json:"duplicate_of_learning_id"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Priority resolvespec_common.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"`
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
RelatedSkillID resolvespec_common.SqlInt64 `bun:"related_skill_id,type:bigint,nullzero," json:"related_skill_id"`
RelatedThoughtID resolvespec_common.SqlInt64 `bun:"related_thought_id,type:bigint,nullzero," json:"related_thought_id"`
ReviewedAt resolvespec_common.SqlTimeStamp `bun:"reviewed_at,type:timestamptz,nullzero," json:"reviewed_at"`
ReviewedBy resolvespec_common.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
SourceRef resolvespec_common.SqlString `bun:"source_ref,type:text,nullzero," json:"source_ref"`
SourceType resolvespec_common.SqlString `bun:"source_type,type:text,nullzero," json:"source_type"`
Status resolvespec_common.SqlString `bun:"status,type:text,default:'pending',notnull," json:"status"`
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"`
SupersedesLearningID resolvespec_common.SqlInt64 `bun:"supersedes_learning_id,type:bigint,nullzero," json:"supersedes_learning_id"`
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
Area sql_types.SqlString `bun:"area,type:text,default:'other',notnull," json:"area"`
Category sql_types.SqlString `bun:"category,type:text,default:'insight',notnull," json:"category"`
Confidence sql_types.SqlString `bun:"confidence,type:text,default:'hypothesis',notnull," json:"confidence"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Details sql_types.SqlString `bun:"details,type:text,default:'',notnull," json:"details"`
DuplicateOfLearningID sql_types.SqlInt64 `bun:"duplicate_of_learning_id,type:bigint,nullzero," json:"duplicate_of_learning_id"`
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Priority sql_types.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"`
ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
RelatedSkillID sql_types.SqlInt64 `bun:"related_skill_id,type:bigint,nullzero," json:"related_skill_id"`
RelatedThoughtID sql_types.SqlInt64 `bun:"related_thought_id,type:bigint,nullzero," json:"related_thought_id"`
ReviewedAt sql_types.SqlTimeStamp `bun:"reviewed_at,type:timestamptz,nullzero," json:"reviewed_at"`
ReviewedBy sql_types.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
SourceRef sql_types.SqlString `bun:"source_ref,type:text,nullzero," json:"source_ref"`
SourceType sql_types.SqlString `bun:"source_type,type:text,nullzero," json:"source_type"`
Status sql_types.SqlString `bun:"status,type:text,default:'pending',notnull," json:"status"`
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"`
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
}
// TableName returns the table name for ModelPublicLearnings
@@ -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"
}
@@ -3,17 +3,17 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicOauthClients struct {
bun.BaseModel `bun:"table:public.oauth_clients,alias:oauth_clients"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ClientID resolvespec_common.SqlString `bun:"client_id,type:text,notnull," json:"client_id"`
ClientName resolvespec_common.SqlString `bun:"client_name,type:text,default:'',notnull," json:"client_name"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
RedirectUris resolvespec_common.SqlStringArray `bun:"redirect_uris,type:text[],default:'{}',notnull," json:"redirect_uris"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ClientID sql_types.SqlString `bun:"client_id,type:text,notnull," json:"client_id"`
ClientName sql_types.SqlString `bun:"client_name,type:text,default:'',notnull," json:"client_name"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
RedirectUris sql_types.SqlStringArray `bun:"redirect_uris,type:text[],default:'{}',notnull," json:"redirect_uris"`
}
// TableName returns the table name for ModelPublicOauthClients
@@ -3,17 +3,17 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicPersonaArc struct {
bun.BaseModel `bun:"table:public.persona_arc,alias:persona_arc"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
PersonaID int64 `bun:"persona_id,type:bigint,pk," json:"persona_id"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"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"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
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
RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas
@@ -3,14 +3,14 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicPlanDependencies struct {
bun.BaseModel `bun:"table:public.plan_dependencies,alias:plan_dependencies"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
DependsOnPlanID int64 `bun:"depends_on_plan_id,type:bigint,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"depends_on_plan_id"`
PlanID int64 `bun:"plan_id,type:bigint,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"plan_id"`
RelDependsOnPlanID *ModelPublicPlans `bun:"rel:has-one,join:depends_on_plan_id=id" json:"reldependsonplanid,omitempty"` // Has one ModelPublicPlans
@@ -3,14 +3,14 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicPlanGuardrails struct {
bun.BaseModel `bun:"table:public.plan_guardrails,alias:plan_guardrails"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"guardrail_id"`
PlanID int64 `bun:"plan_id,type:bigint,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"plan_id"`
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
@@ -3,14 +3,14 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicPlanRelatedPlans struct {
bun.BaseModel `bun:"table:public.plan_related_plans,alias:plan_related_plans"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
PlanAID int64 `bun:"plan_a_id,type:bigint,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_a_id"`
PlanBID int64 `bun:"plan_b_id,type:bigint,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_b_id"`
RelPlanAID *ModelPublicPlans `bun:"rel:has-one,join:plan_a_id=id" json:"relplanaid,omitempty"` // Has one ModelPublicPlans
@@ -3,14 +3,14 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicPlanSkills struct {
bun.BaseModel `bun:"table:public.plan_skills,alias:plan_skills"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
PlanID int64 `bun:"plan_id,type:bigint,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"plan_id"`
SkillID int64 `bun:"skill_id,type:bigint,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"skill_id"`
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
+19 -17
View File
@@ -3,30 +3,32 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicPlans struct {
bun.BaseModel `bun:"table:public.plans,alias:plans"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CompletedAt resolvespec_common.SqlTimeStamp `bun:"completed_at,type:timestamptz,nullzero," json:"completed_at"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
DueDate resolvespec_common.SqlTimeStamp `bun:"due_date,type:timestamptz,nullzero," json:"due_date"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
LastReviewedAt resolvespec_common.SqlTimeStamp `bun:"last_reviewed_at,type:timestamptz,nullzero," json:"last_reviewed_at"`
Owner resolvespec_common.SqlString `bun:"owner,type:text,nullzero," json:"owner"`
Priority resolvespec_common.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"` // low, medium, high, critical
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
ReviewedBy resolvespec_common.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
Status resolvespec_common.SqlString `bun:"status,type:text,default:'draft',notnull," json:"status"` // draft, active, blocked, completed, cancelled, superseded
SupersedesPlanID resolvespec_common.SqlInt64 `bun:"supersedes_plan_id,type:bigint,nullzero," json:"supersedes_plan_id"`
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CompletedAt sql_types.SqlTimeStamp `bun:"completed_at,type:timestamptz,nullzero," json:"completed_at"`
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"`
DueDate sql_types.SqlTimeStamp `bun:"due_date,type:timestamptz,nullzero," json:"due_date"`
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
LastReviewedAt sql_types.SqlTimeStamp `bun:"last_reviewed_at,type:timestamptz,nullzero," json:"last_reviewed_at"`
Owner sql_types.SqlString `bun:"owner,type:text,nullzero," json:"owner"`
Priority sql_types.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"` // low, medium, high, critical
ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
ReviewedBy sql_types.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
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"`
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
@@ -3,14 +3,14 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicProjectGuardrails struct {
bun.BaseModel `bun:"table:public.project_guardrails,alias:project_guardrails"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull," json:"guardrail_id"`
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
@@ -3,14 +3,14 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicProjectPersonas struct {
bun.BaseModel `bun:"table:public.project_personas,alias:project_personas"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
IsDefault bool `bun:"is_default,type:boolean,default:false,notnull," json:"is_default"`
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
@@ -3,14 +3,14 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicProjectSkills struct {
bun.BaseModel `bun:"table:public.project_skills,alias:project_skills"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Override bool `bun:"override,type:boolean,default:false,notnull," json:"override"`
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
SkillID int64 `bun:"skill_id,type:bigint,notnull," json:"skill_id"`
@@ -3,19 +3,20 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicProjects struct {
bun.BaseModel `bun:"table:public.projects,alias:projects"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,nullzero," json:"description"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
LastActiveAt resolvespec_common.SqlTimeStamp `bun:"last_active_at,type:timestamptz,default:now(),nullzero," json:"last_active_at"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
ThoughtCount resolvespec_common.SqlInt64 `bun:"thought_count,scanonly" json:"thought_count"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
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_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
@@ -3,26 +3,28 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicStoredFiles struct {
bun.BaseModel `bun:"table:public.stored_files,alias:stored_files"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
Content []byte `bun:"content,type:bytea,notnull," json:"content"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Encoding resolvespec_common.SqlString `bun:"encoding,type:text,default:'base64',notnull," json:"encoding"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Kind resolvespec_common.SqlString `bun:"kind,type:text,default:'file',notnull," json:"kind"`
MediaType resolvespec_common.SqlString `bun:"media_type,type:text,notnull," json:"media_type"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
Sha256 resolvespec_common.SqlString `bun:"sha256,type:text,notnull," json:"sha256"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Encoding sql_types.SqlString `bun:"encoding,type:text,default:'base64',notnull," json:"encoding"`
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Kind sql_types.SqlString `bun:"kind,type:text,default:'file',notnull," json:"kind"`
MediaType sql_types.SqlString `bun:"media_type,type:text,notnull," json:"media_type"`
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
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"`
ThoughtID resolvespec_common.SqlInt64 `bun:"thought_id,type:bigint,nullzero," json:"thought_id"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
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"
}
@@ -0,0 +1,64 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicThoughtLearningLinks struct {
bun.BaseModel `bun:"table:public.thought_learning_links,alias:thought_learning_links"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
LearningID int64 `bun:"learning_id,type:bigint,notnull,unique:uidx_thought_learning_links_thought_id_learning_id," json:"learning_id"`
Relation sql_types.SqlString `bun:"relation,type:text,default:'source',notnull," json:"relation"`
ThoughtID int64 `bun:"thought_id,type:bigint,notnull,unique:uidx_thought_learning_links_thought_id_learning_id," json:"thought_id"`
RelLearningID *ModelPublicLearnings `bun:"rel:has-one,join:learning_id=id" json:"rellearningid,omitempty"` // Has one ModelPublicLearnings
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
}
// TableName returns the table name for ModelPublicThoughtLearningLinks
func (m ModelPublicThoughtLearningLinks) TableName() string {
return "public.thought_learning_links"
}
// TableNameOnly returns the table name without schema for ModelPublicThoughtLearningLinks
func (m ModelPublicThoughtLearningLinks) TableNameOnly() string {
return "thought_learning_links"
}
// SchemaName returns the schema name for ModelPublicThoughtLearningLinks
func (m ModelPublicThoughtLearningLinks) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicThoughtLearningLinks) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicThoughtLearningLinks) GetIDStr() string {
return m.ID.String()
}
// SetID sets the primary key value
func (m ModelPublicThoughtLearningLinks) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicThoughtLearningLinks) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicThoughtLearningLinks) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicThoughtLearningLinks) GetPrefix() string {
return "TLL"
}
@@ -3,16 +3,16 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicThoughtLinks struct {
bun.BaseModel `bun:"table:public.thought_links,alias:thought_links"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
FromID int64 `bun:"from_id,type:bigint,notnull," json:"from_id"`
Relation resolvespec_common.SqlString `bun:"relation,type:text,notnull," json:"relation"`
Relation sql_types.SqlString `bun:"relation,type:text,notnull," json:"relation"`
ToID int64 `bun:"to_id,type:bigint,notnull," json:"to_id"`
RelFromID *ModelPublicThoughts `bun:"rel:has-one,join:from_id=id" json:"relfromid,omitempty"` // Has one ModelPublicThoughts
RelToID *ModelPublicThoughts `bun:"rel:has-one,join:to_id=id" json:"reltoid,omitempty"` // Has one ModelPublicThoughts
@@ -3,23 +3,26 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicThoughts struct {
bun.BaseModel `bun:"table:public.thoughts,alias:thoughts"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ArchivedAt resolvespec_common.SqlTimeStamp `bun:"archived_at,type:timestamptz,nullzero," json:"archived_at"`
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Metadata resolvespec_common.SqlJSONB `bun:"metadata,type:jsonb,default:{}::jsonb,nullzero," json:"metadata"`
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),nullzero," json:"updated_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
ArchivedAt sql_types.SqlTimeStamp `bun:"archived_at,type:timestamptz,nullzero," json:"archived_at"`
Content sql_types.SqlString `bun:"content,type:text,notnull," json:"content"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
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"`
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
RelThoughtIDPublicEmbeddings []*ModelPublicEmbeddings `bun:"rel:has-many,join:id=thought_id" json:"relthoughtidpublicembeddings,omitempty"` // Has many ModelPublicEmbeddings
RelThoughtIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:id=thought_id" json:"relthoughtidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
RelRelatedThoughtIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=related_thought_id" json:"relrelatedthoughtidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
@@ -3,17 +3,17 @@ package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
"github.com/uptrace/bun"
)
type ModelPublicToolAnnotations struct {
bun.BaseModel `bun:"table:public.tool_annotations,alias:tool_annotations"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,default:'',notnull," json:"notes"`
ToolName resolvespec_common.SqlString `bun:"tool_name,type:text,notnull," json:"tool_name"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Notes sql_types.SqlString `bun:"notes,type:text,default:'',notnull," json:"notes"`
ToolName sql_types.SqlString `bun:"tool_name,type:text,notnull," json:"tool_name"`
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
}
// TableName returns the table name for ModelPublicToolAnnotations
+8
View File
@@ -28,6 +28,7 @@ type ToolSet struct {
Update *tools.UpdateTool
Delete *tools.DeleteTool
Archive *tools.ArchiveTool
DuplicateAudit *tools.DuplicateAuditTool
Projects *tools.ProjectsTool
Context *tools.ContextTool
Recall *tools.RecallTool
@@ -227,6 +228,12 @@ func registerThoughtTools(server *mcp.Server, logger *slog.Logger, toolSet ToolS
}, toolSet.Archive.Handle); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "audit_duplicates",
Description: "Dry-run duplicate audit for projects, thoughts, and metadata normalization candidates; performs no writes.",
}, toolSet.DuplicateAudit.Handle); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "summarize_thoughts",
Description: "LLM summary of a filtered set of thoughts.",
@@ -764,6 +771,7 @@ func BuildToolCatalog() []tools.ToolEntry {
{Name: "update_thought", Description: "Update thought content or merge metadata.", Category: "thoughts"},
{Name: "delete_thought", Description: "Hard-delete a thought by id.", Category: "thoughts"},
{Name: "archive_thought", Description: "Archive a thought so it is hidden from default search and listing.", Category: "thoughts"},
{Name: "audit_duplicates", Description: "Dry-run duplicate audit for exact/normalized project names, thought content, and metadata value variants. Reports candidates and recommendations; performs no writes.", Category: "admin"},
{Name: "summarize_thoughts", Description: "Produce an LLM prose summary of a filtered or searched set of thoughts.", Category: "thoughts"},
{Name: "recall_context", Description: "Recall semantically relevant and recent context for prompt injection. Combines vector similarity with recency. Falls back to full-text search when no embeddings exist.", Category: "thoughts"},
{Name: "link_thoughts", Description: "Create a typed relationship between two thoughts.", Category: "thoughts"},
+6 -5
View File
@@ -15,10 +15,10 @@ 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, name, media_type, kind, encoding, size_bytes, sha256, content)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
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, file.Name, file.MediaType, file.Kind, file.Encoding, file.SizeBytes, file.SHA256, file.Content)
`, file.ThoughtID, file.ProjectID, tenantKeyPtr(ctx), file.Name, file.MediaType, file.Kind, file.Encoding, file.SizeBytes, file.SHA256, file.Content)
var model generatedmodels.ModelPublicStoredFiles
if err := row.Scan(
@@ -42,11 +42,11 @@ func (db *DB) InsertStoredFile(ctx context.Context, file thoughttypes.StoredFile
}
func (db *DB) GetStoredFile(ctx context.Context, id uuid.UUID) (thoughttypes.StoredFile, error) {
args := []any{id}
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
`, id)
where guid = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
var model generatedmodels.ModelPublicStoredFiles
if err := row.Scan(
@@ -77,6 +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_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
}
+17 -9
View File
@@ -14,10 +14,10 @@ import (
func (db *DB) CreateProject(ctx context.Context, name, description string) (thoughttypes.Project, error) {
row := db.pool.QueryRow(ctx, `
insert into projects (name, description)
values ($1, $2)
insert into projects (name, description, tenant_id)
values ($1, $2, $3)
returning id, guid, name, description, created_at, last_active_at
`, name, description)
`, name, description, tenantKeyPtr(ctx))
var model generatedmodels.ModelPublicProjects
if err := row.Scan(&model.ID, &model.GUID, &model.Name, &model.Description, &model.CreatedAt, &model.LastActiveAt); err != nil {
@@ -45,20 +45,20 @@ func (db *DB) GetProject(ctx context.Context, nameOrID string) (thoughttypes.Pro
}
func (db *DB) getProjectByGUID(ctx context.Context, id uuid.UUID) (thoughttypes.Project, error) {
args := []any{id}
row := db.pool.QueryRow(ctx, `
select id, guid, name, description, created_at, last_active_at
from projects
where guid = $1
`, id)
where guid = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
return scanProject(row)
}
func (db *DB) getProjectByName(ctx context.Context, name string) (thoughttypes.Project, error) {
args := []any{name}
row := db.pool.QueryRow(ctx, `
select id, guid, name, description, created_at, last_active_at
from projects
where name = $1
`, name)
where name = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
return scanProject(row)
}
@@ -74,13 +74,20 @@ func scanProject(row pgx.Row) (thoughttypes.Project, error) {
}
func (db *DB) ListProjects(ctx context.Context) ([]thoughttypes.ProjectSummary, error) {
args := []any{}
where := ""
if key, ok := tenantKey(ctx); ok {
args = append(args, key)
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
from projects p
left join thoughts t on t.project_id = p.id and t.archived_at is null
`+where+`
group by p.id, p.guid, p.name, p.description, p.created_at, p.last_active_at
order by p.last_active_at desc, p.created_at desc
`)
`, args...)
if err != nil {
return nil, fmt.Errorf("list projects: %w", err)
}
@@ -105,7 +112,8 @@ func (db *DB) ListProjects(ctx context.Context) ([]thoughttypes.ProjectSummary,
}
func (db *DB) TouchProject(ctx context.Context, id int64) error {
tag, err := db.pool.Exec(ctx, `update projects set last_active_at = now() where id = $1`, id)
args := []any{id}
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
+34
View File
@@ -0,0 +1,34 @@
package store
import (
"context"
"fmt"
"git.warky.dev/wdevs/amcs/internal/tenancy"
)
func tenantKeyPtr(ctx context.Context) *string {
if key, ok := tenancy.KeyFromContext(ctx); ok {
return &key
}
return nil
}
func tenantKey(ctx context.Context) (string, bool) {
return tenancy.KeyFromContext(ctx)
}
func addTenantCondition(ctx context.Context, args *[]any, conditions *[]string, column string) {
if key, ok := tenancy.KeyFromContext(ctx); ok {
*args = append(*args, key)
*conditions = append(*conditions, fmt.Sprintf("%s = $%d", column, len(*args)))
}
}
func tenantSQL(ctx context.Context, args *[]any, column string) string {
if key, ok := tenancy.KeyFromContext(ctx); ok {
*args = append(*args, key)
return fmt.Sprintf(" and %s = $%d", column, len(*args))
}
return ""
}
+26 -15
View File
@@ -31,10 +31,10 @@ func (db *DB) InsertThought(ctx context.Context, thought thoughttypes.Thought, e
}()
row := tx.QueryRow(ctx, `
insert into thoughts (content, metadata, project_id)
values ($1, $2::jsonb, $3)
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)
`, thought.Content, metadata, thought.ProjectID, tenantKeyPtr(ctx))
created := thought
created.Embedding = nil
@@ -123,6 +123,7 @@ func (db *DB) ListThoughts(ctx context.Context, filter thoughttypes.ListFilter)
args := make([]any, 0, 6)
conditions := []string{}
addTenantCondition(ctx, &args, &conditions, "tenant_id")
if !filter.IncludeArchived {
conditions = append(conditions, "archived_at is null")
}
@@ -186,11 +187,14 @@ func (db *DB) ListThoughts(ctx context.Context, filter thoughttypes.ListFilter)
func (db *DB) Stats(ctx context.Context) (thoughttypes.ThoughtStats, error) {
var total int
if err := db.pool.QueryRow(ctx, `select count(*) from thoughts where archived_at is null`).Scan(&total); err != nil {
statsArgs := []any{}
statsConditions := []string{"archived_at is null"}
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)
}
rows, err := db.pool.Query(ctx, `select metadata from thoughts where archived_at is null`)
rows, err := db.pool.Query(ctx, `select metadata from thoughts where `+strings.Join(statsConditions, " and "), statsArgs...)
if err != nil {
return thoughttypes.ThoughtStats{}, fmt.Errorf("query stats metadata: %w", err)
}
@@ -233,11 +237,11 @@ func (db *DB) Stats(ctx context.Context) (thoughttypes.ThoughtStats, error) {
}
func (db *DB) GetThought(ctx context.Context, id uuid.UUID) (thoughttypes.Thought, error) {
args := []any{id}
row := db.pool.QueryRow(ctx, `
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
from thoughts
where guid = $1
`, id)
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 {
@@ -256,11 +260,11 @@ func (db *DB) GetThought(ctx context.Context, id uuid.UUID) (thoughttypes.Though
}
func (db *DB) GetThoughtByID(ctx context.Context, id int64) (thoughttypes.Thought, error) {
args := []any{id}
row := db.pool.QueryRow(ctx, `
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
from thoughts
where id = $1
`, id)
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 {
@@ -292,14 +296,14 @@ func (db *DB) UpdateThought(ctx context.Context, id uuid.UUID, content string, e
_ = tx.Rollback(ctx)
}()
args := []any{id, content, metadataBytes, projectID}
tag, err := tx.Exec(ctx, `
update thoughts
set content = $2,
metadata = $3::jsonb,
project_id = $4,
updated_at = now()
where guid = $1
`, id, content, metadataBytes, projectID)
where guid = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
if err != nil {
return thoughttypes.Thought{}, fmt.Errorf("update thought: %w", err)
}
@@ -333,12 +337,12 @@ func (db *DB) UpdateThoughtMetadata(ctx context.Context, id int64, metadata thou
return thoughttypes.Thought{}, fmt.Errorf("marshal updated metadata: %w", err)
}
args := []any{id, metadataBytes}
tag, err := db.pool.Exec(ctx, `
update thoughts
set metadata = $2::jsonb,
updated_at = now()
where id = $1
`, id, metadataBytes)
where id = $1`+tenantSQL(ctx, &args, "tenant_id"), args...)
if err != nil {
return thoughttypes.Thought{}, fmt.Errorf("update thought metadata: %w", err)
}
@@ -350,7 +354,8 @@ func (db *DB) UpdateThoughtMetadata(ctx context.Context, id int64, metadata thou
}
func (db *DB) DeleteThought(ctx context.Context, id uuid.UUID) error {
tag, err := db.pool.Exec(ctx, `delete from thoughts where guid = $1`, id)
args := []any{id}
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)
}
@@ -361,7 +366,8 @@ func (db *DB) DeleteThought(ctx context.Context, id uuid.UUID) error {
}
func (db *DB) ArchiveThought(ctx context.Context, id uuid.UUID) error {
tag, err := db.pool.Exec(ctx, `update thoughts set archived_at = now(), updated_at = now() where guid = $1`, id)
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_id"), args...)
if err != nil {
return fmt.Errorf("archive thought: %w", err)
}
@@ -440,6 +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_id")
if projectID != nil {
args = append(args, *projectID)
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
@@ -488,6 +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_id")
if projectID != nil {
args = append(args, *projectID)
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
@@ -506,6 +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_id")
if !includeArchived {
conditions = append(conditions, "t.archived_at is null")
@@ -555,6 +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_id")
if !includeArchived {
conditions = append(conditions, "archived_at is null")
@@ -624,6 +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_id")
if projectID != nil {
args = append(args, *projectID)
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
+30
View File
@@ -0,0 +1,30 @@
package tenancy
import (
"context"
"strings"
)
type contextKey string
const tenantKeyContextKey contextKey = "tenancy.tenant_key"
// WithTenantKey returns a context scoped to the authenticated tenant boundary.
// The tenant key is intentionally opaque; today it is the authenticated API key
// or OAuth client id, and callers should not interpret it as a human username.
func WithTenantKey(ctx context.Context, tenantKey string) context.Context {
tenantKey = strings.TrimSpace(tenantKey)
if tenantKey == "" {
return ctx
}
return context.WithValue(ctx, tenantKeyContextKey, tenantKey)
}
func KeyFromContext(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
value, ok := ctx.Value(tenantKeyContextKey).(string)
value = strings.TrimSpace(value)
return value, ok && value != ""
}
+305
View File
@@ -0,0 +1,305 @@
package tools
import (
"context"
"regexp"
"sort"
"strings"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/session"
"git.warky.dev/wdevs/amcs/internal/store"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
const defaultDuplicateAuditLimit = 50
var duplicateWhitespace = regexp.MustCompile(`\s+`)
type DuplicateAuditTool struct {
store *store.DB
search config.SearchConfig
sessions *session.ActiveProjects
}
type DuplicateAuditInput struct {
Project string `json:"project,omitempty" jsonschema:"optional project name or id to scope thought duplicate scanning"`
Limit int `json:"limit,omitempty" jsonschema:"maximum candidate groups to return"`
ThoughtLimit int `json:"thought_limit,omitempty" jsonschema:"maximum thoughts to scan for duplicate content; defaults to search limit"`
IncludeArchived bool `json:"include_archived,omitempty" jsonschema:"include archived thoughts in the scan"`
}
type DuplicateAuditOutput struct {
Report DuplicateAuditReport `json:"report"`
}
type DuplicateAuditReport struct {
Summary DuplicateAuditSummary `json:"summary"`
ProjectCandidates []DuplicateProjectCandidate `json:"project_candidates"`
ThoughtCandidates []DuplicateThoughtCandidate `json:"thought_candidates"`
MetadataCandidates []MetadataCleanupCandidate `json:"metadata_candidates"`
RecommendedNextStep string `json:"recommended_next_step"`
}
type DuplicateAuditSummary struct {
DryRun bool `json:"dry_run"`
ScannedProjects int `json:"scanned_projects"`
ScannedThoughts int `json:"scanned_thoughts"`
ProjectCandidateGroups int `json:"project_candidate_groups"`
ThoughtCandidateGroups int `json:"thought_candidate_groups"`
MetadataCandidateGroups int `json:"metadata_candidate_groups"`
Truncated bool `json:"truncated"`
}
type DuplicateProjectCandidate struct {
MatchType string `json:"match_type"`
NormalizedValue string `json:"normalized_value"`
Confidence float64 `json:"confidence"`
RecommendedCanonicalID uuid.UUID `json:"recommended_canonical_id"`
Projects []thoughttypes.ProjectSummary `json:"projects"`
}
type DuplicateThoughtCandidate struct {
MatchType string `json:"match_type"`
NormalizedValue string `json:"normalized_value"`
Confidence float64 `json:"confidence"`
RecommendedCanonicalID uuid.UUID `json:"recommended_canonical_id"`
Thoughts []thoughttypes.Thought `json:"thoughts"`
}
type MetadataCleanupCandidate struct {
Field string `json:"field"`
NormalizedValue string `json:"normalized_value"`
Values []string `json:"values"`
Occurrences int `json:"occurrences"`
RecommendedValue string `json:"recommended_value"`
RecommendedAction string `json:"recommended_action"`
}
func NewDuplicateAuditTool(db *store.DB, search config.SearchConfig, sessions *session.ActiveProjects) *DuplicateAuditTool {
return &DuplicateAuditTool{store: db, search: search, sessions: sessions}
}
func (t *DuplicateAuditTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in DuplicateAuditInput) (*mcp.CallToolResult, DuplicateAuditOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
if err != nil {
return nil, DuplicateAuditOutput{}, err
}
var projectID *int64
if project != nil {
projectID = &project.NumericID
}
projects, err := t.store.ListProjects(ctx)
if err != nil {
return nil, DuplicateAuditOutput{}, err
}
thoughtLimit := normalizeLimit(in.ThoughtLimit, t.search)
if in.ThoughtLimit <= 0 {
thoughtLimit = normalizeLimit(0, t.search)
}
thoughts, err := t.store.ListThoughts(ctx, thoughttypes.ListFilter{Limit: thoughtLimit, ProjectID: projectID, IncludeArchived: in.IncludeArchived})
if err != nil {
return nil, DuplicateAuditOutput{}, err
}
if project != nil {
_ = t.store.TouchProject(ctx, project.NumericID)
}
return nil, DuplicateAuditOutput{Report: buildDuplicateAuditReport(projects, thoughts, in)}, nil
}
func buildDuplicateAuditReport(projects []thoughttypes.ProjectSummary, thoughts []thoughttypes.Thought, in DuplicateAuditInput) DuplicateAuditReport {
limit := in.Limit
if limit <= 0 {
limit = defaultDuplicateAuditLimit
}
projectCandidates, projectTruncated := duplicateProjectCandidates(projects, limit)
thoughtCandidates, thoughtTruncated := duplicateThoughtCandidates(thoughts, limit)
metadataCandidates, metadataTruncated := metadataCleanupCandidates(thoughts, limit)
return DuplicateAuditReport{
Summary: DuplicateAuditSummary{
DryRun: true,
ScannedProjects: len(projects),
ScannedThoughts: len(thoughts),
ProjectCandidateGroups: len(projectCandidates),
ThoughtCandidateGroups: len(thoughtCandidates),
MetadataCandidateGroups: len(metadataCandidates),
Truncated: projectTruncated || thoughtTruncated || metadataTruncated,
},
ProjectCandidates: projectCandidates,
ThoughtCandidates: thoughtCandidates,
MetadataCandidates: metadataCandidates,
RecommendedNextStep: "Review candidates manually, then use explicit archive/update/merge tools; this audit performs no writes.",
}
}
func duplicateProjectCandidates(projects []thoughttypes.ProjectSummary, limit int) ([]DuplicateProjectCandidate, bool) {
groups := map[string][]thoughttypes.ProjectSummary{}
for _, project := range projects {
key := normalizeDuplicateText(project.Name)
if key != "" {
groups[key] = append(groups[key], project)
}
}
keys := sortedDuplicateKeys(groups)
out := make([]DuplicateProjectCandidate, 0, min(len(keys), limit))
for _, key := range keys {
items := groups[key]
sort.SliceStable(items, func(i, j int) bool {
if items[i].ThoughtCount != items[j].ThoughtCount {
return items[i].ThoughtCount > items[j].ThoughtCount
}
return items[i].CreatedAt.Before(items[j].CreatedAt)
})
matchType := "normalized_name"
if allProjectNamesExact(items) {
matchType = "exact_name"
}
out = append(out, DuplicateProjectCandidate{MatchType: matchType, NormalizedValue: key, Confidence: 1.0, RecommendedCanonicalID: items[0].ID, Projects: items})
if len(out) == limit {
return out, len(keys) > limit
}
}
return out, false
}
func duplicateThoughtCandidates(thoughts []thoughttypes.Thought, limit int) ([]DuplicateThoughtCandidate, bool) {
groups := map[string][]thoughttypes.Thought{}
for _, thought := range thoughts {
key := normalizeDuplicateText(thought.Content)
if key != "" {
groups[key] = append(groups[key], thought)
}
}
keys := sortedDuplicateKeys(groups)
out := make([]DuplicateThoughtCandidate, 0, min(len(keys), limit))
for _, key := range keys {
items := groups[key]
sort.SliceStable(items, func(i, j int) bool { return items[i].CreatedAt.Before(items[j].CreatedAt) })
matchType := "normalized_content"
if allThoughtContentExact(items) {
matchType = "exact_content"
}
out = append(out, DuplicateThoughtCandidate{MatchType: matchType, NormalizedValue: key, Confidence: 1.0, RecommendedCanonicalID: items[0].GUID, Thoughts: items})
if len(out) == limit {
return out, len(keys) > limit
}
}
return out, false
}
func metadataCleanupCandidates(thoughts []thoughttypes.Thought, limit int) ([]MetadataCleanupCandidate, bool) {
groups := map[string]map[string]int{}
add := func(field, value string) {
trimmed := strings.TrimSpace(value)
key := normalizeDuplicateText(trimmed)
if key == "" || trimmed == "" {
return
}
bucketKey := field + "\x00" + key
if groups[bucketKey] == nil {
groups[bucketKey] = map[string]int{}
}
groups[bucketKey][trimmed]++
}
for _, thought := range thoughts {
add("type", thought.Metadata.Type)
add("source", thought.Metadata.Source)
for _, topic := range thought.Metadata.Topics {
add("topics", topic)
}
for _, person := range thought.Metadata.People {
add("people", person)
}
}
keys := make([]string, 0, len(groups))
for key, values := range groups {
if len(values) > 1 {
keys = append(keys, key)
}
}
sort.Strings(keys)
out := make([]MetadataCleanupCandidate, 0, min(len(keys), limit))
for _, key := range keys {
parts := strings.SplitN(key, "\x00", 2)
values, occurrences, recommended := valuesByCount(groups[key])
out = append(out, MetadataCleanupCandidate{
Field: parts[0],
NormalizedValue: parts[1],
Values: values,
Occurrences: occurrences,
RecommendedValue: recommended,
RecommendedAction: "preview bulk metadata remap before applying; no changes made by audit",
})
if len(out) == limit {
return out, len(keys) > limit
}
}
return out, false
}
func sortedDuplicateKeys[T any](groups map[string][]T) []string {
keys := make([]string, 0, len(groups))
for key, items := range groups {
if len(items) > 1 {
keys = append(keys, key)
}
}
sort.Strings(keys)
return keys
}
func valuesByCount(counts map[string]int) ([]string, int, string) {
values := make([]string, 0, len(counts))
occurrences := 0
for value, count := range counts {
values = append(values, value)
occurrences += count
}
sort.Slice(values, func(i, j int) bool {
if counts[values[i]] != counts[values[j]] {
return counts[values[i]] > counts[values[j]]
}
return values[i] < values[j]
})
return values, occurrences, values[0]
}
func normalizeDuplicateText(value string) string {
return duplicateWhitespace.ReplaceAllString(strings.ToLower(strings.TrimSpace(value)), " ")
}
func allProjectNamesExact(projects []thoughttypes.ProjectSummary) bool {
if len(projects) == 0 {
return false
}
first := strings.TrimSpace(projects[0].Name)
for _, project := range projects[1:] {
if strings.TrimSpace(project.Name) != first {
return false
}
}
return true
}
func allThoughtContentExact(thoughts []thoughttypes.Thought) bool {
if len(thoughts) == 0 {
return false
}
first := strings.TrimSpace(thoughts[0].Content)
for _, thought := range thoughts[1:] {
if strings.TrimSpace(thought.Content) != first {
return false
}
}
return true
}
+76
View File
@@ -0,0 +1,76 @@
package tools
import (
"testing"
"time"
"github.com/google/uuid"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
func TestBuildDuplicateAuditGroupsProjectsAndThoughtsSafely(t *testing.T) {
now := time.Date(2026, 4, 12, 20, 5, 0, 0, time.UTC)
projects := []thoughttypes.ProjectSummary{
{Project: thoughttypes.Project{ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), NumericID: 1, Name: "AMCS", CreatedAt: now}, ThoughtCount: 3},
{Project: thoughttypes.Project{ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"), NumericID: 2, Name: "amcs ", CreatedAt: now.Add(time.Hour)}, ThoughtCount: 1},
{Project: thoughttypes.Project{ID: uuid.MustParse("33333333-3333-3333-3333-333333333333"), NumericID: 3, Name: "other", CreatedAt: now}, ThoughtCount: 1},
}
thoughts := []thoughttypes.Thought{
{ID: 10, GUID: uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), Content: "Same text", Metadata: thoughttypes.ThoughtMetadata{Type: "Note", Topics: []string{"Go", "go"}, People: []string{"Alice"}}, CreatedAt: now},
{ID: 11, GUID: uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), Content: " same text ", Metadata: thoughttypes.ThoughtMetadata{Type: "note", Topics: []string{"go"}, People: []string{"alice"}}, CreatedAt: now.Add(time.Hour)},
{ID: 12, GUID: uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc"), Content: "unique", Metadata: thoughttypes.ThoughtMetadata{Type: "Task", Topics: []string{"Tasks"}}, CreatedAt: now},
}
report := buildDuplicateAuditReport(projects, thoughts, DuplicateAuditInput{})
if len(report.ProjectCandidates) != 1 {
t.Fatalf("project candidates = %d, want 1: %#v", len(report.ProjectCandidates), report.ProjectCandidates)
}
projectCandidate := report.ProjectCandidates[0]
if projectCandidate.MatchType != "normalized_name" || projectCandidate.NormalizedValue != "amcs" || len(projectCandidate.Projects) != 2 {
t.Fatalf("project candidate mismatch: %#v", projectCandidate)
}
if projectCandidate.RecommendedCanonicalID != projects[0].ID {
t.Fatalf("recommended project = %s, want %s", projectCandidate.RecommendedCanonicalID, projects[0].ID)
}
if len(report.ThoughtCandidates) != 1 {
t.Fatalf("thought candidates = %d, want 1: %#v", len(report.ThoughtCandidates), report.ThoughtCandidates)
}
thoughtCandidate := report.ThoughtCandidates[0]
if thoughtCandidate.MatchType != "normalized_content" || thoughtCandidate.NormalizedValue != "same text" || len(thoughtCandidate.Thoughts) != 2 {
t.Fatalf("thought candidate mismatch: %#v", thoughtCandidate)
}
if thoughtCandidate.RecommendedCanonicalID != thoughts[0].GUID {
t.Fatalf("recommended thought = %s, want %s", thoughtCandidate.RecommendedCanonicalID, thoughts[0].GUID)
}
if len(report.MetadataCandidates) != 3 {
t.Fatalf("metadata candidates = %d, want 3: %#v", len(report.MetadataCandidates), report.MetadataCandidates)
}
if report.Summary.ProjectCandidateGroups != 1 || report.Summary.ThoughtCandidateGroups != 1 || report.Summary.MetadataCandidateGroups != 3 {
t.Fatalf("summary mismatch: %#v", report.Summary)
}
if report.Summary.DryRun != true {
t.Fatalf("dry run = false, want true")
}
}
func TestBuildDuplicateAuditRespectsLimit(t *testing.T) {
projects := []thoughttypes.ProjectSummary{
{Project: thoughttypes.Project{ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), Name: "Alpha"}},
{Project: thoughttypes.Project{ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"), Name: "alpha"}},
{Project: thoughttypes.Project{ID: uuid.MustParse("33333333-3333-3333-3333-333333333333"), Name: "Beta"}},
{Project: thoughttypes.Project{ID: uuid.MustParse("44444444-4444-4444-4444-444444444444"), Name: "beta"}},
}
report := buildDuplicateAuditReport(projects, nil, DuplicateAuditInput{Limit: 1})
if len(report.ProjectCandidates) != 1 {
t.Fatalf("project candidates = %d, want 1", len(report.ProjectCandidates))
}
if report.Summary.Truncated != true {
t.Fatalf("truncated = false, want true")
}
}
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
-- Per-user tenancy: authenticate key/client id becomes an opaque tenant boundary.
-- Existing rows remain in the legacy unscoped tenant (NULL) for single-tenant installs.
alter table projects add column if not exists tenant_key text;
alter table thoughts add column if not exists tenant_key text;
alter table stored_files add column if not exists tenant_key text;
alter table learnings add column if not exists tenant_key text;
alter table plans add column if not exists tenant_key text;
alter table chat_histories add column if not exists tenant_key text;
-- Project names are now unique inside a tenant rather than globally.
alter table projects drop constraint if exists ukey_projects_name;
alter table projects drop constraint if exists projects_name_key;
drop index if exists projects_name_key;
create unique index if not exists projects_tenant_key_name_idx
on projects (coalesce(tenant_key, ''), name);
create index if not exists projects_tenant_key_idx on projects (tenant_key);
create index if not exists thoughts_tenant_key_idx on thoughts (tenant_key);
create index if not exists thoughts_tenant_key_project_id_idx on thoughts (tenant_key, project_id);
create index if not exists stored_files_tenant_key_idx on stored_files (tenant_key);
create index if not exists learnings_tenant_key_idx on learnings (tenant_key);
create index if not exists plans_tenant_key_idx on plans (tenant_key);
create index if not exists chat_histories_tenant_key_idx on chat_histories (tenant_key);
+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()`]
+13 -1
View File
@@ -6,16 +6,28 @@ Table thoughts {
created_at timestamptz [default: `now()`]
updated_at timestamptz [default: `now()`]
project_id bigint [ref: > projects.id]
tenant_id text [ref: > tenants.id]
archived_at timestamptz
indexes {
tenant_id
(tenant_id, project_id)
}
}
Table projects {
id bigserial [pk]
guid uuid [unique, not null, default: `gen_random_uuid()`]
name text [unique, not null]
name text [not null]
description text
tenant_id text [ref: > tenants.id]
created_at timestamptz [default: `now()`]
last_active_at timestamptz [default: `now()`]
indexes {
(tenant_id, name) [unique]
tenant_id
}
}
Table thought_links {
+2
View File
@@ -3,6 +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_id text [ref: > tenants.id]
name text [not null]
media_type text [not null]
kind text [not null, default: 'file']
@@ -16,6 +17,7 @@ Table stored_files {
indexes {
thought_id
project_id
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
View File
@@ -6,6 +6,7 @@ Table chat_histories {
channel text
agent_id text
project_id bigint [ref: > projects.id]
tenant_id text [ref: > tenants.id]
messages jsonb [not null, default: `'[]'`]
summary text
metadata jsonb [not null, default: `'{}'`]
@@ -15,6 +16,7 @@ Table chat_histories {
indexes {
session_id
project_id
tenant_id
channel
agent_id
created_at
@@ -46,6 +48,7 @@ Table learnings {
source_type text
source_ref text
project_id bigint [ref: > projects.id]
tenant_id text [ref: > tenants.id]
related_thought_id bigint [ref: > thoughts.id]
related_skill_id bigint [ref: > agent_skills.id]
reviewed_by text
@@ -58,6 +61,7 @@ Table learnings {
indexes {
project_id
tenant_id
category
area
status
+2
View File
@@ -6,6 +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_id text [ref: > tenants.id]
owner text
due_date timestamptz
completed_at timestamptz
@@ -18,6 +19,7 @@ Table plans {
indexes {
project_id
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[];
};
+73
View File
@@ -0,0 +1,73 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2025 wdevs
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,136 @@
# sqltypes
Nullable SQL types for hand-written or generated Go models. Each type wraps a
value with a `Valid` flag and implements `database/sql.Scanner`,
`driver.Valuer`, `encoding/json`, `gopkg.in/yaml.v3`, and `encoding/xml`
marshalling — so a single struct field can be scanned from a database row,
round-tripped through JSON/YAML/XML, and written back to the database without
any per-format glue code.
This package is what the `bun` and `gorm` writers emit when generating models
with `--types sqltypes` (see [`pkg/writers/bun`](../writers/bun/README.md) and
[`pkg/writers/gorm`](../writers/gorm/README.md)). It can also be imported
directly in hand-written models.
## Import
```go
import sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
```
## Scalar types
All scalar types are instantiations of the generic `SqlNull[T]`:
| Type | Underlying | Typical SQL type |
|---|---|---|
| `SqlInt16` | `int16` | `smallint` |
| `SqlInt32` | `int32` | `integer` |
| `SqlInt64` | `int64` | `bigint` |
| `SqlFloat32` | `float32` | `real`, `float4` |
| `SqlFloat64` | `float64` | `double precision`, `numeric`, `decimal`, `money` |
| `SqlBool` | `bool` | `boolean` |
| `SqlString` | `string` | `text`, `varchar`, `char`, `citext`, `inet`, `cidr`, `macaddr` |
| `SqlByteArray` | `[]byte` | `bytea` (base64-encoded in JSON/YAML/XML) |
| `SqlUUID` | `uuid.UUID` (`github.com/google/uuid`) | `uuid` |
You can also instantiate `SqlNull[T]` directly for any type not covered
above, e.g. `SqlNull[MyEnum]`.
### Date/time types
Plain `time.Time` doesn't distinguish date-only, time-only, and timestamp
semantics, and its zero value marshals to a confusing `0001-01-01T00:00:00Z`.
These wrapper types fix both problems:
| Type | Format | Notes |
|---|---|---|
| `SqlTimeStamp` | `2006-01-02T15:04:05` | Full timestamp |
| `SqlDate` | `2006-01-02` | Date only |
| `SqlTime` | `15:04:05` | Time only |
Zero/pre-epoch values (`time.Time{}` or anything before `0002-01-01`) marshal
to `null` and `Value()` returns `nil`, instead of leaking Go's zero-time
sentinel into the database or API responses.
### JSON types
| Type | Underlying | Notes |
|---|---|---|
| `SqlJSONB` | `[]byte` | Raw JSON bytes; `MarshalYAML` decodes to native YAML mappings/sequences instead of an embedded JSON string |
| `SqlJSON` | `= SqlJSONB` | Alias — PostgreSQL's `json` and `jsonb` share the same Go representation |
`SqlJSONB` has `AsMap()` / `AsSlice()` helpers for pulling out
`map[string]any` / `[]any` without a separate `json.Unmarshal` call.
### Vector type (pgvector)
`SqlVector` wraps `[]float32` for the `vector` column type ([pgvector](https://github.com/pgvector/pgvector)),
scanning/writing the `[1,2,3]` literal format pgvector uses over the wire.
## Array types
PostgreSQL array columns (`text[]`, `integer[]`, …) map to `SqlXxxArray`
types, each wrapping `Val []T` + `Valid bool` and handling PostgreSQL's
`{a,b,c}` array literal format on `Scan`/`Value`:
`SqlStringArray`, `SqlInt16Array`, `SqlInt32Array`, `SqlInt64Array`,
`SqlFloat32Array`, `SqlFloat64Array`, `SqlBoolArray`, `SqlUUIDArray`.
## Constructing values
Every type has a `NewSqlXxx(v)` constructor that sets `Valid: true`:
```go
name := sql_types.NewSqlString("Ada Lovelace")
age := sql_types.NewSqlInt32(36)
tags := sql_types.NewSqlStringArray([]string{"engineer", "mathematician"})
```
The zero value of any type (`sql_types.SqlString{}`) is null/invalid — use it
directly for a `NULL` field instead of a separate constructor.
Generic helpers:
```go
sql_types.Null(v, valid) // SqlNull[T]{Val: v, Valid: valid}
sql_types.NewSql[T](anyValue) // best-effort conversion from any Go value
```
## Reading values back
Each scalar type has typed accessors that return the zero value instead of
panicking when `Valid` is false:
```go
n.Int64() // SqlInt16/32/64, SqlFloat32/64, SqlBool, SqlString → int64
n.Float64() // → float64
n.Bool() // → bool
n.Time() // SqlNull[time.Time]-based types → time.Time
n.UUID() // SqlUUID → uuid.UUID
n.String() // fmt.Stringer — empty string when invalid
```
## Example
```go
type User struct {
ID sql_types.SqlUUID `json:"id"`
Name sql_types.SqlString `json:"name"`
Tags sql_types.SqlStringArray `json:"tags"`
Metadata sql_types.SqlJSONB `json:"metadata"`
CreatedAt sql_types.SqlTimeStamp `json:"created_at"`
}
u := User{
ID: sql_types.NewSqlUUID(uuid.New()),
Name: sql_types.NewSqlString("Ada Lovelace"),
Tags: sql_types.NewSqlStringArray([]string{"engineer"}),
CreatedAt: sql_types.SqlTimeStampNow(),
}
// Metadata left as the zero value → serializes as null, scans as NULL.
```
Every type implements `sql.Scanner` and `driver.Valuer`, so these fields can
be used directly as struct fields with `database/sql`, `bun`, or `gorm`
without additional tags or hooks.
@@ -1,15 +1,85 @@
package spectypes
package sqltypes
import (
"database/sql/driver"
"encoding/json"
"encoding/xml"
"fmt"
"strconv"
"strings"
"github.com/google/uuid"
"gopkg.in/yaml.v3"
)
// marshalYAMLSlice returns the value used by yaml.Marshaler implementations
// for the nullable array types below: nil when invalid, the slice otherwise.
func marshalYAMLSlice[T any](valid bool, vals []T) (any, error) {
if !valid {
return nil, nil
}
return vals, nil
}
// unmarshalYAMLSlice returns the value used by yaml.Unmarshaler
// implementations for the nullable array types below.
func unmarshalYAMLSlice[T any](value *yaml.Node) (vals []T, valid bool, err error) {
if value == nil || value.Tag == "!!null" {
return nil, false, nil
}
if err := value.Decode(&vals); err != nil {
return nil, false, err
}
return vals, true, nil
}
// xmlArrayItemName is the element name used for each item when a nullable
// array type is encoded as XML, since XML has no native list type.
var xmlArrayItemName = xml.Name{Local: "item"}
// marshalXMLSlice writes a nullable array type as XML: an empty element when
// invalid, otherwise the start tag followed by one <item> child per element.
func marshalXMLSlice[T any](e *xml.Encoder, start xml.StartElement, valid bool, vals []T) error {
if !valid {
return e.EncodeElement("", start)
}
if err := e.EncodeToken(start); err != nil {
return err
}
for _, v := range vals {
if err := e.EncodeElement(v, xml.StartElement{Name: xmlArrayItemName}); err != nil {
return err
}
}
return e.EncodeToken(start.End())
}
// unmarshalXMLSlice reads back the XML written by marshalXMLSlice.
//
// XML has no native null representation: an element with no <item> children
// unmarshals to a valid, empty slice rather than an invalid one.
func unmarshalXMLSlice[T any](d *xml.Decoder, start xml.StartElement) ([]T, error) {
var vals []T
for {
tok, err := d.Token()
if err != nil {
return nil, err
}
switch t := tok.(type) {
case xml.StartElement:
var v T
if err := d.DecodeElement(&v, &t); err != nil {
return nil, err
}
vals = append(vals, v)
case xml.EndElement:
if t.Name == start.Name {
return vals, nil
}
}
}
}
// parsePostgresArrayElements parses a PostgreSQL array literal (e.g. `{a,"b,c",d}`)
// into a slice of raw string elements. Each element retains its unquoted/unescaped value.
func parsePostgresArrayElements(s string) ([]string, error) {
@@ -140,6 +210,32 @@ func (a *SqlStringArray) UnmarshalJSON(b []byte) error {
return nil
}
func (a SqlStringArray) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlStringArray) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[string](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlStringArray) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlStringArray) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[string](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlStringArray(v []string) SqlStringArray {
return SqlStringArray{Val: v, Valid: true}
}
@@ -215,6 +311,32 @@ func (a *SqlInt16Array) UnmarshalJSON(b []byte) error {
return nil
}
func (a SqlInt16Array) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlInt16Array) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[int16](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlInt16Array) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlInt16Array) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[int16](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlInt16Array(v []int16) SqlInt16Array {
return SqlInt16Array{Val: v, Valid: true}
}
@@ -290,6 +412,32 @@ func (a *SqlInt32Array) UnmarshalJSON(b []byte) error {
return nil
}
func (a SqlInt32Array) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlInt32Array) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[int32](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlInt32Array) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlInt32Array) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[int32](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlInt32Array(v []int32) SqlInt32Array {
return SqlInt32Array{Val: v, Valid: true}
}
@@ -365,6 +513,32 @@ func (a *SqlInt64Array) UnmarshalJSON(b []byte) error {
return nil
}
func (a SqlInt64Array) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlInt64Array) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[int64](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlInt64Array) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlInt64Array) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[int64](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlInt64Array(v []int64) SqlInt64Array {
return SqlInt64Array{Val: v, Valid: true}
}
@@ -440,6 +614,32 @@ func (a *SqlFloat32Array) UnmarshalJSON(b []byte) error {
return nil
}
func (a SqlFloat32Array) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlFloat32Array) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[float32](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlFloat32Array) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlFloat32Array) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[float32](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlFloat32Array(v []float32) SqlFloat32Array {
return SqlFloat32Array{Val: v, Valid: true}
}
@@ -515,6 +715,32 @@ func (a *SqlFloat64Array) UnmarshalJSON(b []byte) error {
return nil
}
func (a SqlFloat64Array) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlFloat64Array) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[float64](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlFloat64Array) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlFloat64Array) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[float64](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlFloat64Array(v []float64) SqlFloat64Array {
return SqlFloat64Array{Val: v, Valid: true}
}
@@ -591,6 +817,32 @@ func (a *SqlBoolArray) UnmarshalJSON(b []byte) error {
return nil
}
func (a SqlBoolArray) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlBoolArray) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[bool](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlBoolArray) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlBoolArray) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[bool](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlBoolArray(v []bool) SqlBoolArray {
return SqlBoolArray{Val: v, Valid: true}
}
@@ -666,6 +918,32 @@ func (a *SqlUUIDArray) UnmarshalJSON(b []byte) error {
return nil
}
func (a SqlUUIDArray) MarshalYAML() (any, error) {
return marshalYAMLSlice(a.Valid, a.Val)
}
func (a *SqlUUIDArray) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[uuid.UUID](value)
if err != nil {
return err
}
a.Val, a.Valid = vals, valid
return nil
}
func (a SqlUUIDArray) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, a.Valid, a.Val)
}
func (a *SqlUUIDArray) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[uuid.UUID](d, start)
if err != nil {
return err
}
a.Val, a.Valid = vals, true
return nil
}
func NewSqlUUIDArray(v []uuid.UUID) SqlUUIDArray {
return SqlUUIDArray{Val: v, Valid: true}
}
@@ -750,6 +1028,32 @@ func (v *SqlVector) UnmarshalJSON(b []byte) error {
return nil
}
func (v SqlVector) MarshalYAML() (any, error) {
return marshalYAMLSlice(v.Valid, v.Val)
}
func (v *SqlVector) UnmarshalYAML(value *yaml.Node) error {
vals, valid, err := unmarshalYAMLSlice[float32](value)
if err != nil {
return err
}
v.Val, v.Valid = vals, valid
return nil
}
func (v SqlVector) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
return marshalXMLSlice(e, start, v.Valid, v.Val)
}
func (v *SqlVector) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
vals, err := unmarshalXMLSlice[float32](d, start)
if err != nil {
return err
}
v.Val, v.Valid = vals, true
return nil
}
func NewSqlVector(val []float32) SqlVector {
return SqlVector{Val: val, Valid: true}
}
@@ -1,11 +1,12 @@
// Package spectypes provides nullable SQL types with automatic casting and conversion methods.
package spectypes
// Package sqltypes provides nullable SQL types with automatic casting and conversion methods.
package sqltypes
import (
"database/sql"
"database/sql/driver"
"encoding/base64"
"encoding/json"
"encoding/xml"
"fmt"
"reflect"
"strconv"
@@ -13,6 +14,7 @@ import (
"time"
"github.com/google/uuid"
"gopkg.in/yaml.v3"
)
// tryParseDT attempts to parse a string into a time.Time using various formats.
@@ -120,15 +122,22 @@ func (n *SqlNull[T]) FromString(s string) error {
var zero T
switch any(zero).(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
case int, int8, int16, int32, int64:
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
reflect.ValueOf(&n.Val).Elem().SetInt(i)
n.Valid = true
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
} else if f, err := strconv.ParseFloat(s, 64); err == nil {
reflect.ValueOf(&n.Val).Elem().SetInt(int64(f))
n.Valid = true
}
case uint, uint8, uint16, uint32, uint64:
if u, err := strconv.ParseUint(s, 10, 64); err == nil {
reflect.ValueOf(&n.Val).Elem().SetUint(u)
n.Valid = true
} else if f, err := strconv.ParseFloat(s, 64); err == nil && f >= 0 {
reflect.ValueOf(&n.Val).Elem().SetUint(uint64(f))
n.Valid = true
}
case float32, float64:
if f, err := strconv.ParseFloat(s, 64); err == nil {
reflect.ValueOf(&n.Val).Elem().SetFloat(f)
@@ -232,6 +241,104 @@ func (n *SqlNull[T]) UnmarshalJSON(b []byte) error {
return fmt.Errorf("cannot unmarshal %s into SqlNull[%T]", b, n.Val)
}
// MarshalYAML implements yaml.Marshaler.
func (n SqlNull[T]) MarshalYAML() (any, error) {
if !n.Valid {
return nil, nil
}
// Check if T is []byte, and encode to base64 (mirrors MarshalJSON).
if b, ok := any(n.Val).([]byte); ok {
return base64.StdEncoding.EncodeToString(b), nil
}
return n.Val, nil
}
// UnmarshalYAML implements yaml.Unmarshaler.
func (n *SqlNull[T]) UnmarshalYAML(value *yaml.Node) error {
if value == nil || value.Tag == "!!null" {
n.Valid = false
n.Val = *new(T)
return nil
}
// Check if T is []byte, and decode from base64.
var zero T
if _, ok := any(zero).([]byte); ok {
var s string
if err := value.Decode(&s); err == nil {
if decoded, err := base64.StdEncoding.DecodeString(s); err == nil {
n.Val = any(decoded).(T)
n.Valid = true
return nil
}
n.Val = any([]byte(s)).(T)
n.Valid = true
return nil
}
}
var val T
if err := value.Decode(&val); err == nil {
n.Val = val
n.Valid = true
return nil
}
// Fallback: decode as string and parse.
var s string
if err := value.Decode(&s); err == nil {
return n.FromString(s)
}
return fmt.Errorf("cannot unmarshal %q into SqlNull[%T]", value.Value, n.Val)
}
// MarshalXML implements xml.Marshaler.
func (n SqlNull[T]) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if !n.Valid {
return e.EncodeElement("", start)
}
// Check if T is []byte, and encode to base64 (mirrors MarshalJSON).
if b, ok := any(n.Val).([]byte); ok {
return e.EncodeElement(base64.StdEncoding.EncodeToString(b), start)
}
return e.EncodeElement(n.Val, start)
}
// UnmarshalXML implements xml.Unmarshaler.
//
// XML has no native null representation, so an empty element unmarshals to
// an invalid (null) value rather than a zero-value-but-valid one.
func (n *SqlNull[T]) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var s string
if err := d.DecodeElement(&s, &start); err != nil {
return err
}
if s == "" {
n.Valid = false
n.Val = *new(T)
return nil
}
var zero T
if _, ok := any(zero).([]byte); ok {
if decoded, err := base64.StdEncoding.DecodeString(s); err == nil {
n.Val = any(decoded).(T)
n.Valid = true
return nil
}
n.Val = any([]byte(s)).(T)
n.Valid = true
return nil
}
return n.FromString(s)
}
// String implements fmt.Stringer.
func (n SqlNull[T]) String() string {
if !n.Valid {
@@ -329,6 +436,7 @@ type (
SqlInt16 = SqlNull[int16]
SqlInt32 = SqlNull[int32]
SqlInt64 = SqlNull[int64]
SqlFloat32 = SqlNull[float32]
SqlFloat64 = SqlNull[float64]
SqlBool = SqlNull[bool]
SqlString = SqlNull[string]
@@ -343,7 +451,7 @@ func (t SqlTimeStamp) MarshalJSON() ([]byte, error) {
if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) {
return []byte("null"), nil
}
return []byte(fmt.Sprintf(`"%s"`, t.Val.Format("2006-01-02T15:04:05"))), nil
return fmt.Appendf(nil, `"%s"`, t.Val.Format("2006-01-02T15:04:05")), nil
}
func (t *SqlTimeStamp) UnmarshalJSON(b []byte) error {
@@ -363,6 +471,49 @@ func (t SqlTimeStamp) Value() (driver.Value, error) {
return t.Val.Format("2006-01-02T15:04:05"), nil
}
func (t SqlTimeStamp) MarshalYAML() (any, error) {
if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) {
return nil, nil
}
return t.Val.Format("2006-01-02T15:04:05"), nil
}
func (t *SqlTimeStamp) UnmarshalYAML(value *yaml.Node) error {
if err := t.SqlNull.UnmarshalYAML(value); err != nil {
return err
}
if t.Valid && (t.Val.IsZero() || t.Val.Format("2006-01-02T15:04:05") == "0001-01-01T00:00:00") {
t.Valid = false
}
return nil
}
func (t SqlTimeStamp) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if !t.Valid || t.Val.IsZero() || t.Val.Before(time.Date(0002, 1, 1, 0, 0, 0, 0, time.UTC)) {
return e.EncodeElement("", start)
}
return e.EncodeElement(t.Val.Format("2006-01-02T15:04:05"), start)
}
func (t *SqlTimeStamp) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var s string
if err := d.DecodeElement(&s, &start); err != nil {
return err
}
if s == "" {
t.Valid = false
t.Val = time.Time{}
return nil
}
tm, err := tryParseDT(s)
if err != nil {
return err
}
t.Val = tm
t.Valid = !tm.IsZero() && tm.Format("2006-01-02T15:04:05") != "0001-01-01T00:00:00"
return nil
}
func SqlTimeStampNow() SqlTimeStamp {
return SqlTimeStamp{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
}
@@ -378,7 +529,7 @@ func (d SqlDate) MarshalJSON() ([]byte, error) {
if strings.HasPrefix(s, "0001-01-01") {
return []byte("null"), nil
}
return []byte(fmt.Sprintf(`"%s"`, s)), nil
return fmt.Appendf(nil, `"%s"`, s), nil
}
func (d *SqlDate) UnmarshalJSON(b []byte) error {
@@ -413,6 +564,57 @@ func (d SqlDate) String() string {
return s
}
func (d SqlDate) MarshalYAML() (any, error) {
if !d.Valid || d.Val.IsZero() {
return nil, nil
}
s := d.Val.Format("2006-01-02")
if strings.HasPrefix(s, "0001-01-01") {
return nil, nil
}
return s, nil
}
func (d *SqlDate) UnmarshalYAML(value *yaml.Node) error {
if err := d.SqlNull.UnmarshalYAML(value); err != nil {
return err
}
if d.Valid && d.Val.Format("2006-01-02") <= "0001-01-01" {
d.Valid = false
}
return nil
}
func (d SqlDate) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if !d.Valid || d.Val.IsZero() {
return e.EncodeElement("", start)
}
s := d.Val.Format("2006-01-02")
if strings.HasPrefix(s, "0001-01-01") {
return e.EncodeElement("", start)
}
return e.EncodeElement(s, start)
}
func (d *SqlDate) UnmarshalXML(dec *xml.Decoder, start xml.StartElement) error {
var s string
if err := dec.DecodeElement(&s, &start); err != nil {
return err
}
if s == "" {
d.Valid = false
d.Val = time.Time{}
return nil
}
tm, err := tryParseDT(s)
if err != nil {
return err
}
d.Val = tm
d.Valid = !tm.IsZero() && tm.Format("2006-01-02") > "0001-01-01"
return nil
}
func SqlDateNow() SqlDate {
return SqlDate{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
}
@@ -428,7 +630,7 @@ func (t SqlTime) MarshalJSON() ([]byte, error) {
if s == "00:00:00" {
return []byte("null"), nil
}
return []byte(fmt.Sprintf(`"%s"`, s)), nil
return fmt.Appendf(nil, `"%s"`, s), nil
}
func (t *SqlTime) UnmarshalJSON(b []byte) error {
@@ -455,6 +657,57 @@ func (t SqlTime) String() string {
return t.Val.Format("15:04:05")
}
func (t SqlTime) MarshalYAML() (any, error) {
if !t.Valid || t.Val.IsZero() {
return nil, nil
}
s := t.Val.Format("15:04:05")
if s == "00:00:00" {
return nil, nil
}
return s, nil
}
func (t *SqlTime) UnmarshalYAML(value *yaml.Node) error {
if err := t.SqlNull.UnmarshalYAML(value); err != nil {
return err
}
if t.Valid && t.Val.Format("15:04:05") == "00:00:00" {
t.Valid = false
}
return nil
}
func (t SqlTime) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if !t.Valid || t.Val.IsZero() {
return e.EncodeElement("", start)
}
s := t.Val.Format("15:04:05")
if s == "00:00:00" {
return e.EncodeElement("", start)
}
return e.EncodeElement(s, start)
}
func (t *SqlTime) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var s string
if err := d.DecodeElement(&s, &start); err != nil {
return err
}
if s == "" {
t.Valid = false
t.Val = time.Time{}
return nil
}
tm, err := tryParseDT(s)
if err != nil {
return err
}
t.Val = tm
t.Valid = !tm.IsZero() && tm.Format("15:04:05") != "00:00:00"
return nil
}
func SqlTimeNow() SqlTime {
return SqlTime{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
}
@@ -462,6 +715,11 @@ func SqlTimeNow() SqlTime {
// SqlJSONB - Nullable JSONB as []byte.
type SqlJSONB []byte
// SqlJSON - Nullable JSON as []byte. PostgreSQL's json and jsonb types share
// the same textual representation and Go marshalling behavior, differing only
// in server-side storage, so SqlJSON is an alias of SqlJSONB.
type SqlJSON = SqlJSONB
// Scan implements sql.Scanner.
func (n *SqlJSONB) Scan(value any) error {
if value == nil {
@@ -518,6 +776,67 @@ func (n *SqlJSONB) UnmarshalJSON(b []byte) error {
return nil
}
// MarshalYAML implements yaml.Marshaler. The underlying JSON is decoded into
// a generic value first so it renders as native YAML mappings/sequences
// rather than an embedded JSON string.
func (n SqlJSONB) MarshalYAML() (any, error) {
if len(n) == 0 {
return nil, nil
}
var v any
if err := json.Unmarshal(n, &v); err != nil {
return nil, nil
}
return v, nil
}
// UnmarshalYAML implements yaml.Unmarshaler.
func (n *SqlJSONB) UnmarshalYAML(value *yaml.Node) error {
if value == nil || value.Tag == "!!null" {
*n = nil
return nil
}
var v any
if err := value.Decode(&v); err != nil {
return err
}
b, err := json.Marshal(v)
if err != nil {
return err
}
*n = b
return nil
}
// MarshalXML implements xml.Marshaler. JSON has no clean structural mapping
// to XML, so the raw JSON text is emitted as the element's text content.
func (n SqlJSONB) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
if len(n) == 0 {
return e.EncodeElement("", start)
}
var obj any
if err := json.Unmarshal(n, &obj); err != nil {
return e.EncodeElement("", start)
}
return e.EncodeElement(string(n), start)
}
// UnmarshalXML implements xml.Unmarshaler, reading back the raw JSON text
// written by MarshalXML.
func (n *SqlJSONB) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
var s string
if err := d.DecodeElement(&s, &start); err != nil {
return err
}
s = strings.TrimSpace(s)
if s == "" {
*n = nil
return nil
}
*n = []byte(s)
return nil
}
func (n SqlJSONB) AsMap() (map[string]any, error) {
if len(n) == 0 {
return nil, nil
@@ -625,6 +944,10 @@ func NewSqlInt64(v int64) SqlInt64 {
return SqlInt64{Val: v, Valid: true}
}
func NewSqlFloat32(v float32) SqlFloat32 {
return SqlFloat32{Val: v, Valid: true}
}
func NewSqlFloat64(v float64) SqlFloat64 {
return SqlFloat64{Val: v, Valid: true}
}
+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
@@ -78,6 +78,17 @@ func (s *securityContext) GetUserID() (int, bool) {
return security.GetUserID(s.ctx.Context)
}
// GetUserRef returns an opaque user identifier for row security lookups.
// It prefers the full *security.UserContext (so providers can read JWT claims,
// e.g. a UUID subject) and falls back to the int user ID.
func (s *securityContext) GetUserRef() (any, bool) {
if userCtx, ok := security.GetUserContext(s.ctx.Context); ok {
return userCtx, true
}
userID, ok := security.GetUserID(s.ctx.Context)
return userID, ok
}
func (s *securityContext) GetSchema() string {
return s.ctx.Schema
}
+90 -1
View File
@@ -11,7 +11,8 @@ Type-safe, composable security system for ResolveSpec with support for authentic
- ✅ **No Global State** - Each handler has its own security configuration
- ✅ **Testable** - Easy to mock and test
- ✅ **Extensible** - Implement custom providers for your needs
- ✅ **Stored Procedures** - All database operations use PostgreSQL stored procedures for security and maintainability
- ✅ **Stored Procedures** - Database operations use PostgreSQL stored procedures where available, for security and maintainability
- ✅ **Direct Mode** - Portable Go/SQL fallback for SQLite, MySQL, or Postgres without the stored procedures installed — no code changes required
- ✅ **OAuth2 Authorization Server** - Built-in OAuth 2.1 + PKCE server (RFC 8414, 7591, 7009, 7662) with login form and external provider federation
- ✅ **Password Reset** - Self-service password reset with secure token generation and session invalidation
@@ -51,6 +52,94 @@ Type-safe, composable security system for ResolveSpec with support for authentic
See `database_schema.sql` for complete stored procedure definitions and examples.
**Not on Postgres, or don't have the procedures installed?** See [Direct Mode](#direct-mode-portable-sql-without-stored-procedures) below — every provider that calls a `resolvespec_*` procedure also has a portable Go/SQL implementation that works on SQLite, MySQL, or plain Postgres.
## Direct Mode (portable SQL without stored procedures)
Every database-backed provider (`DatabaseAuthenticator`, `JWTAuthenticator`, `DatabaseTwoFactorProvider`, `DatabasePasskeyProvider`, the OAuth2 methods/server, `DatabaseKeyStore`) has two code paths:
- **Procedure mode** — calls the configured `resolvespec_*` stored procedure (original behavior, Postgres-only).
- **Direct mode** — reimplements the same logic in Go using plain parameterized SQL against configurable table names. Works on SQLite, MySQL, or a Postgres database where the procedures were never deployed.
### QueryMode
Selection is controlled per-provider by a `QueryMode`:
```go
type QueryMode int
const (
ModeAuto QueryMode = iota // default
ModeProcedure
ModeDirect
)
```
- **`ModeAuto`** (default, zero value) — auto-detects per connection:
- SQLite/MySQL drivers → Direct mode, no probing.
- Postgres drivers (`lib/pq`, `pgx`) → probes `pg_proc` for the configured procedure name and uses it **only if it actually exists**; otherwise falls back to Direct mode. The result is cached per procedure name and reset on reconnect.
- Any other/unrecognized driver (including `sqlmock` test doubles) → defaults to Procedure mode, preserving existing behavior for callers that don't expose an identifiable driver type.
- **`ModeProcedure`** — always calls the stored procedure, regardless of dialect.
- **`ModeDirect`** — always uses the portable Go/SQL path, never the stored procedure.
Set it via the provider's `Options` struct or `With...` chain method:
```go
auth := security.NewDatabaseAuthenticatorWithOptions(db, security.DatabaseAuthenticatorOptions{
QueryMode: security.ModeDirect, // force Direct mode, e.g. for SQLite
})
tfaProvider := security.NewDatabaseTwoFactorProvider(sqliteDB, nil).
WithQueryMode(security.ModeDirect)
```
On a real SQLite/MySQL connection you can usually leave `QueryMode` unset — `ModeAuto` detects the dialect and uses Direct mode automatically.
### TableNames / KeyStoreTableNames
Direct mode reads/writes plain tables instead of calling procedures, so table names are configurable the same way procedure names are (`SQLNames`):
```go
type TableNames struct {
Users string // default: "users"
UserSessions string // default: "user_sessions"
TokenBlacklist string // default: "token_blacklist"
UserTOTPBackupCodes string // default: "user_totp_backup_codes"
UserPasskeyCredentials string // default: "user_passkey_credentials"
UserPasswordResets string // default: "user_password_resets"
OAuthClients string // default: "oauth_clients"
OAuthCodes string // default: "oauth_codes"
}
type KeyStoreTableNames struct {
UserKeys string // default: "user_keys" — used by DatabaseKeyStore
}
```
`DefaultTableNames()` / `MergeTableNames()` / `ValidateTableNames()` mirror `DefaultSQLNames()` / `MergeSQLNames()` / `ValidateSQLNames()`. Set custom names via the same `Options`/`With...` surface as `QueryMode`:
```go
auth := security.NewDatabaseAuthenticatorWithOptions(db, security.DatabaseAuthenticatorOptions{
TableNames: &security.TableNames{Users: "app_users"}, // only override what differs
})
```
`oauth2_methods.go` and `oauth_server_db.go` are methods on `*DatabaseAuthenticator` and reuse its `TableNames`/`QueryMode`; there's no separate config for them.
### Schema
`database_schema_sqlite.sql` is the portable companion to `database_schema.sql` — plain `CREATE TABLE` statements only (no functions, no triggers, no `jsonb`/`bytea`/array types), covering every table Direct mode reads or writes. Use it to stand up a SQLite (or adapt for MySQL) database for Direct mode.
### What's NOT covered
`ColumnSecurityProvider`/`RowSecurityProvider` (`resolvespec_column_security` / `resolvespec_row_security`) query an external `core.secaccess`/`core.hub_link` schema this package doesn't own. Direct mode has no portable equivalent to fabricate for these and returns `security.ErrDirectModeUnsupported` — use `ConfigColumnSecurityProvider`/`ConfigRowSecurityProvider` instead when not running against Postgres with those procedures installed.
### Behavioral notes
- Direct mode matches Procedure mode's current behavior exactly, including its TODOs — e.g. passwords are compared as-is (the stored procedures don't verify bcrypt hashes yet either; see the TODO in `resolvespec_login`/`resolvespec_password_reset`).
- Session tokens generated by Direct mode use the same `sess_<hex>_<unix-timestamp>` shape as the plpgsql procedures.
- `bytea`/array/`jsonb` Postgres-only columns (passkey credentials, OAuth2 client scopes, keystore `meta`) are stored as base64/JSON-encoded `TEXT` in Direct mode — transparent to callers, since the Go-level API already deals in those same encodings.
## Quick Start
```go
+2 -2
View File
@@ -74,8 +74,8 @@ func (c *CompositeSecurityProvider) GetColumnSecurity(ctx context.Context, userI
}
// GetRowSecurity delegates to the row security provider
func (c *CompositeSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
return c.rowSec.GetRowSecurity(ctx, userID, schema, table)
func (c *CompositeSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
return c.rowSec.GetRowSecurity(ctx, userRef, schema, table)
}
// Optional interface implementations (if wrapped providers support them)
@@ -0,0 +1,141 @@
-- Portable schema for Direct-mode (non-stored-procedure) operation.
-- Plain CREATE TABLE statements only, no functions/triggers, using types
-- understood by SQLite (and portable to MySQL). Used by Direct-mode tests
-- and as a reference for deployments without Postgres.
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255),
user_level INTEGER DEFAULT 0,
roles VARCHAR(500),
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP,
updated_at TIMESTAMP,
last_login_at TIMESTAMP,
program_user_id INTEGER DEFAULT 0,
program_user_table VARCHAR(255) DEFAULT '',
remote_id VARCHAR(255),
auth_provider VARCHAR(50),
totp_secret VARCHAR(255),
totp_enabled BOOLEAN DEFAULT 0,
totp_enabled_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_token VARCHAR(500) NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP,
last_activity_at TIMESTAMP,
ip_address VARCHAR(45),
user_agent TEXT,
access_token TEXT,
refresh_token TEXT,
token_type VARCHAR(50) DEFAULT 'Bearer',
auth_provider VARCHAR(50)
);
CREATE INDEX IF NOT EXISTS idx_session_token ON user_sessions(session_token);
CREATE INDEX IF NOT EXISTS idx_user_id ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_expires_at ON user_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_refresh_token ON user_sessions(refresh_token);
CREATE TABLE IF NOT EXISTS token_blacklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token VARCHAR(500) NOT NULL,
user_id INTEGER,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_totp_backup_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
code_hash VARCHAR(64) NOT NULL,
used BOOLEAN DEFAULT 0,
used_at TIMESTAMP,
created_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_totp_user_id ON user_totp_backup_codes(user_id);
CREATE INDEX IF NOT EXISTS idx_totp_code_hash ON user_totp_backup_codes(code_hash);
CREATE TABLE IF NOT EXISTS user_passkey_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
credential_id TEXT NOT NULL UNIQUE, -- base64 text (Direct mode), not native bytea
public_key TEXT NOT NULL, -- base64 text
attestation_type VARCHAR(50) DEFAULT 'none',
aaguid TEXT, -- base64 text
sign_count INTEGER DEFAULT 0,
clone_warning BOOLEAN DEFAULT 0,
transports TEXT, -- JSON-encoded []string
backup_eligible BOOLEAN DEFAULT 0,
backup_state BOOLEAN DEFAULT 0,
name VARCHAR(255),
created_at TIMESTAMP,
last_used_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_passkey_user_id ON user_passkey_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_passkey_credential_id ON user_passkey_credentials(credential_id);
CREATE TABLE IF NOT EXISTS user_password_resets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP,
used BOOLEAN DEFAULT 0,
used_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS oauth_clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id VARCHAR(255) NOT NULL UNIQUE,
redirect_uris TEXT NOT NULL, -- JSON-encoded []string
client_name VARCHAR(255),
grant_types TEXT, -- JSON-encoded []string
allowed_scopes TEXT, -- JSON-encoded []string
is_active BOOLEAN DEFAULT 1,
created_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS oauth_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code VARCHAR(255) NOT NULL UNIQUE,
client_id VARCHAR(255) NOT NULL,
redirect_uri TEXT NOT NULL,
client_state TEXT,
code_challenge VARCHAR(255) NOT NULL,
code_challenge_method VARCHAR(10) DEFAULT 'S256',
session_token TEXT NOT NULL,
refresh_token TEXT,
scopes TEXT, -- JSON-encoded []string
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_codes_code ON oauth_codes(code);
CREATE INDEX IF NOT EXISTS idx_oauth_codes_expires ON oauth_codes(expires_at);
CREATE TABLE IF NOT EXISTS user_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
key_type VARCHAR(50) NOT NULL,
key_hash VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL DEFAULT '',
scopes TEXT, -- JSON-encoded []string
meta TEXT, -- JSON-encoded map
expires_at TIMESTAMP,
created_at TIMESTAMP,
last_used_at TIMESTAMP,
is_active BOOLEAN DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_user_keys_user_id ON user_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_user_keys_key_hash ON user_keys(key_hash);
CREATE INDEX IF NOT EXISTS idx_user_keys_key_type ON user_keys(key_type);
+22 -8
View File
@@ -14,6 +14,11 @@ import (
type SecurityContext interface {
GetContext() context.Context
GetUserID() (int, bool)
// GetUserRef returns an opaque user identifier for row security lookups.
// Unlike GetUserID, it is not required to be an integer: implementations backed by
// non-integer identifiers (e.g. UUIDs) can return a string, or the full
// *security.UserContext so a RowSecurityProvider can read JWT claims directly.
GetUserRef() (any, bool)
GetSchema() string
GetEntity() string
GetModel() interface{}
@@ -45,8 +50,13 @@ func loadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error
// return err
}
// Load row security rules using the provider
_, err = securityList.LoadRowSecurity(secCtx.GetContext(), userID, schema, tablename, false)
// Load row security rules using the provider. Row security uses the opaque
// user ref (not the int-only user ID) so non-integer user identifiers work.
userRef, refOK := secCtx.GetUserRef()
if !refOK {
userRef = userID
}
_, err = securityList.LoadRowSecurity(secCtx.GetContext(), userRef, schema, tablename, false)
if err != nil {
logger.Warn("Failed to load row security: %v", err)
// Don't fail the request if no security rules exist
@@ -58,25 +68,29 @@ func loadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error
// applyRowSecurity applies row-level security filters to the query (generic version)
func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error {
userID, ok := secCtx.GetUserID()
userRef, ok := secCtx.GetUserRef()
if !ok {
userID, idOK := secCtx.GetUserID()
if !idOK {
return nil // No user context, skip
}
userRef = userID
}
schema := secCtx.GetSchema()
tablename := secCtx.GetEntity()
// Get row security template
rowSec, err := securityList.GetRowSecurityTemplate(userID, schema, tablename)
rowSec, err := securityList.GetRowSecurityTemplate(userRef, schema, tablename)
if err != nil {
// No row security defined, allow query to proceed
logger.Debug("No row security for %s.%s@%d: %v", schema, tablename, userID, err)
logger.Debug("No row security for %s.%s@%v: %v", schema, tablename, userRef, err)
return nil
}
// Check if user has a blocking rule
if rowSec.HasBlock {
logger.Warn("User %d blocked from accessing %s.%s", userID, schema, tablename)
logger.Warn("User %v blocked from accessing %s.%s", userRef, schema, tablename)
return fmt.Errorf("access denied to %s", tablename)
}
@@ -112,8 +126,8 @@ func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error
// Generate the WHERE clause from template
whereClause := rowSec.GetTemplate(pkName, modelType)
logger.Info("Applying row security filter for user %d on %s.%s: %s",
userID, schema, tablename, whereClause)
logger.Info("Applying row security filter for user %v on %s.%s: %s",
userRef, schema, tablename, whereClause)
// Apply the WHERE clause to the query
query := secCtx.GetQuery()
+6 -2
View File
@@ -121,8 +121,12 @@ type ColumnSecurityProvider interface {
// RowSecurityProvider handles row-level security (filtering)
type RowSecurityProvider interface {
// GetRowSecurity loads row security rules for a user and entity
GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error)
// GetRowSecurity loads row security rules for a user and entity.
// userRef identifies the user and is opaque to the caller: it may be an int ID,
// a string/UUID, or the full *security.UserContext (see SecurityContext.GetUserRef),
// so providers backed by non-integer user identifiers (e.g. UUIDs) or that need
// access to JWT claims can implement row security without relying on a numeric ID.
GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error)
}
// SecurityProvider is the main interface combining all security concerns

Some files were not shown because too many files have changed in this diff Show More