Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d4e6d0939 | |||
| f94fddddb1 | |||
| 631bb64109 | |||
| 4f8f2f4190 | |||
| 1689167a7b | |||
| 5cd81f325f | |||
| cd010fc7a1 | |||
| e3a4a3c5c7 | |||
| 4b3f0b1b55 | |||
| e459775740 | |||
| 5718685c40 | |||
| cfc78e0493 |
@@ -18,6 +18,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: '1.26'
|
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
|
- name: Cache Go modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -48,6 +56,20 @@ jobs:
|
|||||||
- name: Tidy modules
|
- name: Tidy modules
|
||||||
run: go mod tidy
|
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
|
- name: Run tests
|
||||||
run: go test ./...
|
run: go test ./...
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,38 @@ The AMCS directory is used to store configuration and code for the Avalon Memory
|
|||||||
| `describe_tools` | List all available MCP tools with names, descriptions, categories, and model-authored usage notes; call this at the start of a session to orient yourself |
|
| `describe_tools` | List all available MCP tools with names, descriptions, categories, and model-authored usage notes; call this at the start of a session to orient yourself |
|
||||||
| `annotate_tool` | Persist your own usage notes for a specific tool; notes are returned by `describe_tools` in future sessions |
|
| `annotate_tool` | Persist your own usage notes for a specific tool; notes are returned by `describe_tools` in future sessions |
|
||||||
|
|
||||||
|
## Webhook ingestion
|
||||||
|
|
||||||
|
External automation can create thoughts without speaking MCP by posting JSON to `POST /webhooks/thoughts`. The endpoint is protected by the same AMCS authentication middleware as MCP and file uploads, so pass one configured API key via `x-brain-key`, an authorization bearer token header, or another enabled auth method.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/webhooks/thoughts \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'x-brain-key: <api-key>' \
|
||||||
|
-H 'Idempotency-Key: n8n-run-123' \
|
||||||
|
-d '{
|
||||||
|
"content": "External system observed build failure on main",
|
||||||
|
"project": "amcs",
|
||||||
|
"source": "n8n",
|
||||||
|
"type": "task",
|
||||||
|
"topics": ["ci", "webhook"],
|
||||||
|
"metadata": {"workflow": "ci-monitor", "run_id": "123"}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload fields:
|
||||||
|
|
||||||
|
- `content` is required and becomes the thought content.
|
||||||
|
- `project` is optional; when present it must match an existing AMCS project.
|
||||||
|
- `source`, `type`, `topics`, `people`, `action_items`, and `dates_mentioned` are normalized into the standard thought metadata schema. Unknown `type` values fall back to `observation`.
|
||||||
|
- `metadata` or `source_metadata` may contain safe source-specific JSON values; unsupported values and overly deep objects are dropped rather than persisted.
|
||||||
|
- `idempotency_key` or the `Idempotency-Key` header can be supplied to make repeated webhook deliveries return the existing thought with `duplicate: true`.
|
||||||
|
- `external_id` is stored under `metadata.webhook.external_id` for source-side traceability.
|
||||||
|
|
||||||
|
Successful new ingestion returns `201` with the created thought. Duplicate idempotency-key delivery returns `200` and the previously created thought. Invalid JSON, missing content, missing/unknown projects, or unauthenticated requests are rejected before persistence. Metadata and embedding enrichment are queued after the thought is stored.
|
||||||
|
|
||||||
## Learnings
|
## Learnings
|
||||||
|
|
||||||
Learnings are curated, structured memory records for durable insights you want to keep distinct from raw thoughts. Use them for normalized lessons, decisions, and evidence-backed findings that should be easy to retrieve and review over time.
|
Learnings are curated, structured memory records for durable insights you want to keep distinct from raw thoughts. Use them for normalized lessons, decisions, and evidence-backed findings that should be easy to retrieve and review over time.
|
||||||
@@ -478,7 +510,7 @@ metadata_retry:
|
|||||||
include_archived: false
|
include_archived: false
|
||||||
```
|
```
|
||||||
|
|
||||||
**Search fallback**: when no embeddings exist for the active model in scope, `search_thoughts`, `recall_context`, `get_project_context`, `summarize_thoughts`, and `related_thoughts` automatically fall back to Postgres full-text search so results are never silently empty.
|
**Search fallback**: when no embeddings exist for the active model in scope, `search_thoughts`, `recall_context`, `get_project_context`, `summarize_thoughts`, and `related_thoughts` automatically fall back to Postgres full-text search so results are never silently empty. All five tools include a `retrieval_mode` field in their response (`"semantic"` or `"text"`) so callers can see which path was taken.
|
||||||
|
|
||||||
## Client Setup
|
## Client Setup
|
||||||
|
|
||||||
|
|||||||
@@ -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_key` into the request context. The tenant key is intentionally opaque and currently equals the authenticated key id or OAuth client id.
|
||||||
|
- `internal/tenancy/tenancy.go` exposes `WithTenantKey` and `KeyFromContext` only; callers should not infer user semantics from the tenant string.
|
||||||
|
- Schema already has `tenant_key` on `projects`, `thoughts`, `stored_files`, `learnings`, `plans`, and `chat_histories` in `schema/*.dbml`.
|
||||||
|
- `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_key`.
|
||||||
|
2. OAuth bearer token: resolve token through `TokenStore.Lookup`; use returned client id/key id as `tenant_key`.
|
||||||
|
3. OAuth Basic client credentials: resolve through `OAuthRegistry.Lookup`; use returned client id as `tenant_key`.
|
||||||
|
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_key text` to all user-owned tables:
|
||||||
|
- `projects`
|
||||||
|
- `thoughts`
|
||||||
|
- `stored_files`
|
||||||
|
- `learnings`
|
||||||
|
- `plans`
|
||||||
|
- `chat_histories`
|
||||||
|
- Add tenant indexes:
|
||||||
|
- single-column `tenant_key` for direct filtering
|
||||||
|
- `(tenant_key, name)` unique on `projects`, replacing global `projects.name` uniqueness
|
||||||
|
- `(tenant_key, project_id)` on `thoughts` for common project-scoped memory lookups
|
||||||
|
- 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_key` from context.
|
||||||
|
- Gets/updates/deletes by id or guid must add `and tenant_key = $n`.
|
||||||
|
- Lists/searches must add `tenant_key = $n` before other filters.
|
||||||
|
- 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_key` columns first; do not make them `not null` initially because existing deployments have historical rows without a principal.
|
||||||
|
2. Backfill existing rows to a configured default tenant only if the instance enables multi-tenant mode or defines `auth.default_tenant_key`. Otherwise leave `NULL` rows visible only to unauthenticated/internal single-tenant contexts.
|
||||||
|
3. Replace global project-name uniqueness with `(tenant_key, name)` uniqueness. For PostgreSQL, preserve legacy `NULL` semantics carefully: multiple null-tenant projects with the same name may be possible unless a partial unique index is added for null tenant rows.
|
||||||
|
4. Add indexes concurrently where practical for production-sized tables.
|
||||||
|
5. Document that after enabling auth-backed tenancy, legacy null-tenant data is not visible to authenticated tenants unless backfilled.
|
||||||
|
|
||||||
|
Recommended config addition:
|
||||||
|
|
||||||
|
- `auth.default_tenant_key` or `tenancy.default_key` for one-time/self-hosted backfill and development.
|
||||||
|
- Optional `tenancy.mode: single|authenticated` so operators can keep current single-user behavior deliberately instead of discovering isolation by accident. An accident in auth is just a breach with better branding.
|
||||||
|
|
||||||
|
## Authorization checks
|
||||||
|
|
||||||
|
Authorization is row ownership by tenant key:
|
||||||
|
|
||||||
|
- A request may only see or mutate rows whose `tenant_key` equals the authenticated tenant key.
|
||||||
|
- Cross-tenant ids/gids should behave as not found, not forbidden, to avoid existence leaks.
|
||||||
|
- Tenant key is server-derived only. Ignore any client-provided `tenant_key` fields on tool/API inputs.
|
||||||
|
- 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_key` 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_key` 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.
|
||||||
@@ -3,7 +3,8 @@ module git.warky.dev/wdevs/amcs
|
|||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bitechdev/ResolveSpec v1.1.24
|
git.warky.dev/wdevs/relspecgo v1.0.62
|
||||||
|
github.com/bitechdev/ResolveSpec v1.1.26
|
||||||
github.com/google/jsonschema-go v0.4.3
|
github.com/google/jsonschema-go v0.4.3
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
@@ -46,7 +47,6 @@ require (
|
|||||||
github.com/prometheus/procfs v0.20.1 // indirect
|
github.com/prometheus/procfs v0.20.1 // indirect
|
||||||
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||||
github.com/redis/go-redis/v9 v9.19.0 // 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/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
github.com/segmentio/asm v1.1.3 // indirect
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
github.com/segmentio/encoding v0.5.4 // indirect
|
github.com/segmentio/encoding v0.5.4 // indirect
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
|||||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
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 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
|
||||||
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
|
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.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.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=
|
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/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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
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.26 h1:/OYc1Mjcfm4Qxq8Xy5UY/32l5cSqkNe8ieeOqIEbK5o=
|
||||||
github.com/bitechdev/ResolveSpec v1.1.24/go.mod h1:GF51sMRCWbAyri2WNae3IZAFM/2s6DG6i3eTTrobbVs=
|
github.com/bitechdev/ResolveSpec v1.1.26/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 h1:6Gpm9YYUEQx2T9zMsYolQhr6sjwwGtFitSA0pQsa7a8=
|
||||||
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
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.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
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=
|
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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
|||||||
}
|
}
|
||||||
mux.Handle("/files", authMiddleware(fileHandler(filesTool)))
|
mux.Handle("/files", authMiddleware(fileHandler(filesTool)))
|
||||||
mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool)))
|
mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool)))
|
||||||
|
mux.Handle("/webhooks/thoughts", authMiddleware(newWebhookThoughtHandler(db, embeddings, cfg.Capture, enrichmentRetryer, backfillTool)))
|
||||||
mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler())
|
mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler())
|
||||||
mux.HandleFunc("/api/oauth/register", oauthRegisterHandler(dynClients, logger))
|
mux.HandleFunc("/api/oauth/register", oauthRegisterHandler(dynClients, logger))
|
||||||
mux.HandleFunc("/api/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
|
mux.HandleFunc("/api/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func resolveSpecModels() []resolveSpecModel {
|
|||||||
{schema: "public", entity: "project_skills", model: generatedmodels.ModelPublicProjectSkills{}},
|
{schema: "public", entity: "project_skills", model: generatedmodels.ModelPublicProjectSkills{}},
|
||||||
{schema: "public", entity: "projects", model: generatedmodels.ModelPublicProjects{}},
|
{schema: "public", entity: "projects", model: generatedmodels.ModelPublicProjects{}},
|
||||||
{schema: "public", entity: "stored_files", model: generatedmodels.ModelPublicStoredFiles{}},
|
{schema: "public", entity: "stored_files", model: generatedmodels.ModelPublicStoredFiles{}},
|
||||||
|
{schema: "public", entity: "thought_learning_links", model: generatedmodels.ModelPublicThoughtLearningLinks{}},
|
||||||
{schema: "public", entity: "thought_links", model: generatedmodels.ModelPublicThoughtLinks{}},
|
{schema: "public", entity: "thought_links", model: generatedmodels.ModelPublicThoughtLinks{}},
|
||||||
{schema: "public", entity: "thoughts", model: generatedmodels.ModelPublicThoughts{}},
|
{schema: "public", entity: "thoughts", model: generatedmodels.ModelPublicThoughts{}},
|
||||||
{schema: "public", entity: "tool_annotations", model: generatedmodels.ModelPublicToolAnnotations{}},
|
{schema: "public", entity: "tool_annotations", model: generatedmodels.ModelPublicToolAnnotations{}},
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/ai"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/metadata"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/store"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/tools"
|
||||||
|
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxWebhookBodyBytes = 1 << 20
|
||||||
|
|
||||||
|
type webhookThoughtRequest struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Project string `json:"project,omitempty"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Topics []string `json:"topics,omitempty"`
|
||||||
|
People []string `json:"people,omitempty"`
|
||||||
|
ActionItems []string `json:"action_items,omitempty"`
|
||||||
|
DatesMentioned []string `json:"dates_mentioned,omitempty"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
SourceMetadata map[string]any `json:"source_metadata,omitempty"`
|
||||||
|
IDempotencyKey string `json:"idempotency_key,omitempty"`
|
||||||
|
ExternalID string `json:"external_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookThoughtResponse struct {
|
||||||
|
Thought thoughttypes.Thought `json:"thought"`
|
||||||
|
Duplicate bool `json:"duplicate"`
|
||||||
|
WebhookMeta thoughttypes.WebhookMetadata `json:"webhook"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type webhookThoughtHandler struct {
|
||||||
|
store *store.DB
|
||||||
|
embeddings *ai.EmbeddingRunner
|
||||||
|
capture config.CaptureConfig
|
||||||
|
retryer tools.MetadataQueuer
|
||||||
|
embedRetryer tools.EmbeddingQueuer
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWebhookThoughtHandler(db *store.DB, embeddings *ai.EmbeddingRunner, capture config.CaptureConfig, retryer tools.MetadataQueuer, embedRetryer tools.EmbeddingQueuer) http.Handler {
|
||||||
|
return &webhookThoughtHandler{store: db, embeddings: embeddings, capture: capture, retryer: retryer, embedRetryer: embedRetryer}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *webhookThoughtHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/webhooks/thoughts" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", http.MethodPost)
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes)
|
||||||
|
in, err := parseWebhookThoughtRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookMeta := buildWebhookMetadata(in, r.Header.Get("Idempotency-Key"), time.Now().UTC())
|
||||||
|
if webhookMeta.IDempotencyKey != "" {
|
||||||
|
if existing, err := h.store.GetThoughtByWebhookIDempotencyKey(r.Context(), webhookMeta.IDempotencyKey); err == nil {
|
||||||
|
writeWebhookThoughtResponse(w, http.StatusOK, webhookThoughtResponse{Thought: existing, Duplicate: true, WebhookMeta: webhookMeta})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
projectID, err := h.resolveWebhookProject(r, in.Project)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
thought := thoughttypes.Thought{
|
||||||
|
Content: strings.TrimSpace(in.Content),
|
||||||
|
Metadata: normalizeWebhookThoughtMetadata(in, webhookMeta, h.capture),
|
||||||
|
ProjectID: projectID,
|
||||||
|
}
|
||||||
|
created, err := h.store.InsertThought(r.Context(), thought, h.embeddings.PrimaryModel())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "insert thought: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if projectID != nil {
|
||||||
|
_ = h.store.TouchProject(r.Context(), *projectID)
|
||||||
|
}
|
||||||
|
if h.retryer != nil {
|
||||||
|
h.retryer.QueueThought(created.ID)
|
||||||
|
}
|
||||||
|
if h.embedRetryer != nil {
|
||||||
|
h.embedRetryer.QueueThought(r.Context(), created.ID, created.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeWebhookThoughtResponse(w, http.StatusCreated, webhookThoughtResponse{Thought: created, WebhookMeta: webhookMeta})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWebhookThoughtRequest(r *http.Request) (webhookThoughtRequest, error) {
|
||||||
|
if !strings.Contains(r.Header.Get("Content-Type"), "application/json") {
|
||||||
|
return webhookThoughtRequest{}, errors.New("webhook requires application/json")
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
var in webhookThoughtRequest
|
||||||
|
if err := decoder.Decode(&in); err != nil {
|
||||||
|
return webhookThoughtRequest{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.Content) == "" {
|
||||||
|
return webhookThoughtRequest{}, errors.New("content is required")
|
||||||
|
}
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *webhookThoughtHandler) resolveWebhookProject(r *http.Request, projectName string) (*int64, error) {
|
||||||
|
projectName = strings.TrimSpace(projectName)
|
||||||
|
if projectName == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
project, err := h.store.GetProject(r.Context(), projectName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &project.NumericID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWebhookMetadata(in webhookThoughtRequest, headerKey string, now time.Time) thoughttypes.WebhookMetadata {
|
||||||
|
sourceMetadata := in.SourceMetadata
|
||||||
|
if len(sourceMetadata) == 0 {
|
||||||
|
sourceMetadata = in.Metadata
|
||||||
|
}
|
||||||
|
return thoughttypes.WebhookMetadata{
|
||||||
|
ReceivedAt: now.Format(time.RFC3339),
|
||||||
|
IDempotencyKey: firstNonEmpty(in.IDempotencyKey, headerKey),
|
||||||
|
ExternalID: strings.TrimSpace(in.ExternalID),
|
||||||
|
SourceMetadata: sanitizeWebhookMetadata(sourceMetadata),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWebhookThoughtMetadata(in webhookThoughtRequest, webhookMeta thoughttypes.WebhookMetadata, capture config.CaptureConfig) thoughttypes.ThoughtMetadata {
|
||||||
|
return metadata.Normalize(thoughttypes.ThoughtMetadata{
|
||||||
|
People: in.People,
|
||||||
|
ActionItems: in.ActionItems,
|
||||||
|
DatesMentioned: in.DatesMentioned,
|
||||||
|
Topics: in.Topics,
|
||||||
|
Type: in.Type,
|
||||||
|
Source: firstNonEmpty(in.Source, "webhook"),
|
||||||
|
Webhook: &webhookMeta,
|
||||||
|
}, capture)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeWebhookMetadata(in map[string]any) map[string]any {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(in))
|
||||||
|
for key, value := range in {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sanitized, ok := sanitizeWebhookMetadataValue(value, 0); ok {
|
||||||
|
out[key] = sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeWebhookMetadataValue(value any, depth int) (any, bool) {
|
||||||
|
if depth > 3 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
switch v := value.(type) {
|
||||||
|
case nil, bool, float64, string:
|
||||||
|
return v, true
|
||||||
|
case []any:
|
||||||
|
if len(v) > 50 {
|
||||||
|
v = v[:50]
|
||||||
|
}
|
||||||
|
out := make([]any, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
if sanitized, ok := sanitizeWebhookMetadataValue(item, depth+1); ok {
|
||||||
|
out = append(out, sanitized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
case map[string]any:
|
||||||
|
if len(v) > 50 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return sanitizeWebhookMetadata(v), true
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeWebhookThoughtResponse(w http.ResponseWriter, status int, out webhookThoughtResponse) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseWebhookThoughtRequestRequiresJSON(t *testing.T) {
|
||||||
|
req := httptestRequest("text/plain", `{"content":"hello"}`)
|
||||||
|
|
||||||
|
_, err := parseWebhookThoughtRequest(req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-json content type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseWebhookThoughtRequestRequiresContent(t *testing.T) {
|
||||||
|
req := httptestRequest("application/json", `{"source":"n8n"}`)
|
||||||
|
|
||||||
|
_, err := parseWebhookThoughtRequest(req)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "content is required") {
|
||||||
|
t.Fatalf("error = %v, want content required", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildWebhookMetadataUsesHeaderIdempotencyAndSanitizesMetadata(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 15, 4, 0, 0, 0, time.UTC)
|
||||||
|
got := buildWebhookMetadata(webhookThoughtRequest{
|
||||||
|
ExternalID: " ext-1 ",
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"service": "n8n",
|
||||||
|
"unsafe": struct{}{},
|
||||||
|
"nested": map[string]any{"ok": true},
|
||||||
|
},
|
||||||
|
}, " key-1 ", now)
|
||||||
|
|
||||||
|
if got.IDempotencyKey != "key-1" {
|
||||||
|
t.Fatalf("IDempotencyKey = %q, want key-1", got.IDempotencyKey)
|
||||||
|
}
|
||||||
|
if got.ExternalID != "ext-1" {
|
||||||
|
t.Fatalf("ExternalID = %q, want ext-1", got.ExternalID)
|
||||||
|
}
|
||||||
|
if got.ReceivedAt != "2026-07-15T04:00:00Z" {
|
||||||
|
t.Fatalf("ReceivedAt = %q", got.ReceivedAt)
|
||||||
|
}
|
||||||
|
if _, ok := got.SourceMetadata["unsafe"]; ok {
|
||||||
|
t.Fatal("unsafe metadata value was not removed")
|
||||||
|
}
|
||||||
|
if got.SourceMetadata["service"] != "n8n" {
|
||||||
|
t.Fatalf("service metadata = %#v", got.SourceMetadata["service"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeWebhookThoughtMetadata(t *testing.T) {
|
||||||
|
webhookMeta := buildWebhookMetadata(webhookThoughtRequest{IDempotencyKey: "abc"}, "", time.Date(2026, 7, 15, 4, 0, 0, 0, time.UTC))
|
||||||
|
got := normalizeWebhookThoughtMetadata(webhookThoughtRequest{
|
||||||
|
Source: "github",
|
||||||
|
Type: "task",
|
||||||
|
Topics: []string{"ci", "ci", ""},
|
||||||
|
People: []string{" Sam "},
|
||||||
|
}, webhookMeta, config.CaptureConfig{})
|
||||||
|
|
||||||
|
if got.Source != "github" {
|
||||||
|
t.Fatalf("Source = %q, want github", got.Source)
|
||||||
|
}
|
||||||
|
if got.Type != "task" {
|
||||||
|
t.Fatalf("Type = %q, want task", got.Type)
|
||||||
|
}
|
||||||
|
if len(got.Topics) != 1 || got.Topics[0] != "ci" {
|
||||||
|
t.Fatalf("Topics = %#v, want [ci]", got.Topics)
|
||||||
|
}
|
||||||
|
if got.Webhook == nil || got.Webhook.IDempotencyKey != "abc" {
|
||||||
|
t.Fatalf("Webhook = %#v, want idempotency key abc", got.Webhook)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func httptestRequest(contentType, body string) *http.Request {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/thoughts", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", contentType)
|
||||||
|
return req
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"git.warky.dev/wdevs/amcs/internal/config"
|
"git.warky.dev/wdevs/amcs/internal/config"
|
||||||
"git.warky.dev/wdevs/amcs/internal/observability"
|
"git.warky.dev/wdevs/amcs/internal/observability"
|
||||||
"git.warky.dev/wdevs/amcs/internal/requestip"
|
"git.warky.dev/wdevs/amcs/internal/requestip"
|
||||||
|
"git.warky.dev/wdevs/amcs/internal/tenancy"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
@@ -50,6 +51,10 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
withTenant := func(ctx context.Context, keyID string) context.Context {
|
||||||
|
ctx = context.WithValue(ctx, keyIDContextKey, keyID)
|
||||||
|
return tenancy.WithTenantKey(ctx, keyID)
|
||||||
|
}
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
remoteAddr := requestip.FromRequest(r)
|
remoteAddr := requestip.FromRequest(r)
|
||||||
@@ -63,7 +68,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAccess(r, keyID)
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,14 +78,14 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
if tokenStore != nil {
|
if tokenStore != nil {
|
||||||
if keyID, ok := tokenStore.Lookup(bearer); ok {
|
if keyID, ok := tokenStore.Lookup(bearer); ok {
|
||||||
recordAccess(r, keyID)
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if keyring != nil {
|
if keyring != nil {
|
||||||
if keyID, ok := keyring.Lookup(bearer); ok {
|
if keyID, ok := keyring.Lookup(bearer); ok {
|
||||||
recordAccess(r, keyID)
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +108,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAccess(r, keyID)
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +122,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
recordAccess(r, keyID)
|
recordAccess(r, keyID)
|
||||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID)))
|
next.ServeHTTP(w, r.WithContext(withTenant(r.Context(), keyID)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,21 +3,21 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentGuardrails struct {
|
type ModelPublicAgentGuardrails struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_guardrails,alias:agent_guardrails"`
|
bun.BaseModel `bun:"table:public.agent_guardrails,alias:agent_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"`
|
||||||
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"`
|
Content sql_types.SqlString `bun:"content,type:text,notnull," json:"content"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
Severity resolvespec_common.SqlString `bun:"severity,type:text,default:'medium',notnull," json:"severity"`
|
Severity sql_types.SqlString `bun:"severity,type:text,default:'medium',notnull," json:"severity"`
|
||||||
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
RelGuardrailIDPublicAgentPersonaGuardrails []*ModelPublicAgentPersonaGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicagentpersonaguardrails,omitempty"` // Has many ModelPublicAgentPersonaGuardrails
|
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
|
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
|
RelGuardrailIDPublicProjectGuardrails []*ModelPublicProjectGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicprojectguardrails,omitempty"` // Has many ModelPublicProjectGuardrails
|
||||||
|
|||||||
@@ -3,22 +3,22 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentParts struct {
|
type ModelPublicAgentParts struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_parts,alias:agent_parts"`
|
bun.BaseModel `bun:"table:public.agent_parts,alias:agent_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"`
|
||||||
Content resolvespec_common.SqlString `bun:"content,type:text,default:'',notnull," json:"content"`
|
Content sql_types.SqlString `bun:"content,type:text,default:'',notnull," json:"content"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
PartType resolvespec_common.SqlString `bun:"part_type,type:text,notnull," json:"part_type"`
|
PartType sql_types.SqlString `bun:"part_type,type:text,notnull," json:"part_type"`
|
||||||
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
Summary sql_types.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
||||||
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
RelPartIDPublicAgentPersonaParts []*ModelPublicAgentPersonaParts `bun:"rel:has-many,join:id=part_id" json:"relpartidpublicagentpersonaparts,omitempty"` // Has many ModelPublicAgentPersonaParts
|
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
|
RelPartIDPublicArcStageParts []*ModelPublicArcStageParts `bun:"rel:has-many,join:id=part_id" json:"relpartidpublicarcstageparts,omitempty"` // Has many ModelPublicArcStageParts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentPersonaGuardrails struct {
|
type ModelPublicAgentPersonaGuardrails struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_persona_guardrails,alias:agent_persona_guardrails"`
|
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"`
|
GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull," json:"guardrail_id"`
|
||||||
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_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
|
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentPersonaParts struct {
|
type ModelPublicAgentPersonaParts struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_persona_parts,alias:agent_persona_parts"`
|
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"`
|
PartID int64 `bun:"part_id,type:bigint,notnull," json:"part_id"`
|
||||||
PartOrder int32 `bun:"part_order,type:int,default:0,notnull," json:"part_order"`
|
PartOrder int32 `bun:"part_order,type:int,default:0,notnull," json:"part_order"`
|
||||||
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentPersonaSkills struct {
|
type ModelPublicAgentPersonaSkills struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_persona_skills,alias:agent_persona_skills"`
|
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"`
|
Override bool `bun:"override,type:boolean,default:false,notnull," json:"override"`
|
||||||
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
||||||
SkillID int64 `bun:"skill_id,type:bigint,notnull," json:"skill_id"`
|
SkillID int64 `bun:"skill_id,type:bigint,notnull," json:"skill_id"`
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentPersonaTraits struct {
|
type ModelPublicAgentPersonaTraits struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_persona_traits,alias:agent_persona_traits"`
|
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"`
|
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
||||||
TraitID int64 `bun:"trait_id,type:bigint,notnull," json:"trait_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
|
RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas
|
||||||
|
|||||||
@@ -3,24 +3,24 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentPersonas struct {
|
type ModelPublicAgentPersonas struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_personas,alias:agent_personas"`
|
bun.BaseModel `bun:"table:public.agent_personas,alias:agent_personas"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CompiledAt resolvespec_common.SqlTimeStamp `bun:"compiled_at,type:timestamptz,nullzero," json:"compiled_at"`
|
CompiledAt sql_types.SqlTimeStamp `bun:"compiled_at,type:timestamptz,nullzero," json:"compiled_at"`
|
||||||
CompiledDetail resolvespec_common.SqlString `bun:"compiled_detail,type:text,default:'',notnull," json:"compiled_detail"`
|
CompiledDetail sql_types.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"`
|
CompiledSummary sql_types.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"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
Detail resolvespec_common.SqlString `bun:"detail,type:text,default:'',notnull," json:"detail"`
|
Detail sql_types.SqlString `bun:"detail,type:text,default:'',notnull," json:"detail"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
Summary sql_types.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
||||||
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
RelPersonaIDPublicAgentPersonaParts []*ModelPublicAgentPersonaParts `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicagentpersonaparts,omitempty"` // Has many ModelPublicAgentPersonaParts
|
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
|
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
|
RelPersonaIDPublicProjectPersonas []*ModelPublicProjectPersonas `bun:"rel:has-many,join:id=persona_id" json:"relpersonaidpublicprojectpersonas,omitempty"` // Has many ModelPublicProjectPersonas
|
||||||
|
|||||||
@@ -3,24 +3,24 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentSkills struct {
|
type ModelPublicAgentSkills struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_skills,alias:agent_skills"`
|
bun.BaseModel `bun:"table:public.agent_skills,alias:agent_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"`
|
||||||
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"`
|
Content sql_types.SqlString `bun:"content,type:text,notnull," json:"content"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
DomainTags resolvespec_common.SqlStringArray `bun:"domain_tags,type:text[],default:'{}',notnull," json:"domain_tags"`
|
DomainTags sql_types.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"`
|
FrameworkTags sql_types.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"`
|
GUID sql_types.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"`
|
LanguageTags sql_types.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"`
|
LibraryTags sql_types.SqlStringArray `bun:"library_tags,type:text[],default:'{}',notnull," json:"library_tags"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
RelSkillIDPublicAgentPersonaSkills []*ModelPublicAgentPersonaSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicagentpersonaskills,omitempty"` // Has many ModelPublicAgentPersonaSkills
|
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
|
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
|
RelSkillIDPublicPlanSkills []*ModelPublicPlanSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicplanskills,omitempty"` // Has many ModelPublicPlanSkills
|
||||||
|
|||||||
@@ -3,21 +3,21 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicAgentTraits struct {
|
type ModelPublicAgentTraits struct {
|
||||||
bun.BaseModel `bun:"table:public.agent_traits,alias:agent_traits"`
|
bun.BaseModel `bun:"table:public.agent_traits,alias:agent_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"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
Instruction resolvespec_common.SqlString `bun:"instruction,type:text,default:'',notnull," json:"instruction"`
|
Instruction sql_types.SqlString `bun:"instruction,type:text,default:'',notnull," json:"instruction"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||||
TraitType resolvespec_common.SqlString `bun:"trait_type,type:text,notnull," json:"trait_type"`
|
TraitType sql_types.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"`
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
RelTraitIDPublicAgentPersonaTraits []*ModelPublicAgentPersonaTraits `bun:"rel:has-many,join:id=trait_id" json:"reltraitidpublicagentpersonatraits,omitempty"` // Has many ModelPublicAgentPersonaTraits
|
RelTraitIDPublicAgentPersonaTraits []*ModelPublicAgentPersonaTraits `bun:"rel:has-many,join:id=trait_id" json:"reltraitidpublicagentpersonatraits,omitempty"` // Has many ModelPublicAgentPersonaTraits
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicArcStageParts struct {
|
type ModelPublicArcStageParts struct {
|
||||||
bun.BaseModel `bun:"table:public.arc_stage_parts,alias:arc_stage_parts"`
|
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"`
|
PartID int64 `bun:"part_id,type:bigint,notnull," json:"part_id"`
|
||||||
StageID int64 `bun:"stage_id,type:bigint,notnull," json:"stage_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
|
RelPartID *ModelPublicAgentParts `bun:"rel:has-one,join:part_id=id" json:"relpartid,omitempty"` // Has one ModelPublicAgentParts
|
||||||
|
|||||||
@@ -3,18 +3,18 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicArcStages struct {
|
type ModelPublicArcStages struct {
|
||||||
bun.BaseModel `bun:"table:public.arc_stages,alias:arc_stages"`
|
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"`
|
ArcID int64 `bun:"arc_id,type:bigint,notnull," json:"arc_id"`
|
||||||
Condition resolvespec_common.SqlString `bun:"condition,type:text,default:'',notnull," json:"condition"`
|
Condition sql_types.SqlString `bun:"condition,type:text,default:'',notnull," json:"condition"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
StageOrder int32 `bun:"stage_order,type:int,default:0,notnull," json:"stage_order"`
|
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
|
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
|
RelStageIDPublicArcStageParts []*ModelPublicArcStageParts `bun:"rel:has-many,join:id=stage_id" json:"relstageidpublicarcstageparts,omitempty"` // Has many ModelPublicArcStageParts
|
||||||
|
|||||||
@@ -3,18 +3,18 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicCharacterArcs struct {
|
type ModelPublicCharacterArcs struct {
|
||||||
bun.BaseModel `bun:"table:public.character_arcs,alias:character_arcs"`
|
bun.BaseModel `bun:"table:public.character_arcs,alias:character_arcs"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
Summary resolvespec_common.SqlString `bun:"summary,type:text,default:'',notnull," json:"summary"`
|
Summary sql_types.SqlString `bun:"summary,type:text,default:'',notnull," json:"summary"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
RelArcIDPublicArcStages []*ModelPublicArcStages `bun:"rel:has-many,join:id=arc_id" json:"relarcidpublicarcstages,omitempty"` // Has many ModelPublicArcStages
|
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
|
RelArcIDPublicPersonaArcs []*ModelPublicPersonaArc `bun:"rel:has-many,join:id=arc_id" json:"relarcidpublicpersonaarcs,omitempty"` // Has many ModelPublicPersonaArc
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,25 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicChatHistories struct {
|
type ModelPublicChatHistories struct {
|
||||||
bun.BaseModel `bun:"table:public.chat_histories,alias:chat_histories"`
|
bun.BaseModel `bun:"table:public.chat_histories,alias:chat_histories"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
AgentID resolvespec_common.SqlString `bun:"agent_id,type:text,nullzero," json:"agent_id"`
|
AgentID sql_types.SqlString `bun:"agent_id,type:text,nullzero," json:"agent_id"`
|
||||||
Channel resolvespec_common.SqlString `bun:"channel,type:text,nullzero," json:"channel"`
|
Channel sql_types.SqlString `bun:"channel,type:text,nullzero," json:"channel"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.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"`
|
GUID sql_types.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||||
Messages resolvespec_common.SqlJSONB `bun:"messages,type:jsonb,default:'[]',notnull," json:"messages"`
|
Messages sql_types.SqlJSONB `bun:"messages,type:jsonb,default:'[]',notnull," json:"messages"`
|
||||||
Metadata resolvespec_common.SqlJSONB `bun:"metadata,type:jsonb,default:'{}',notnull," json:"metadata"`
|
Metadata sql_types.SqlJSONB `bun:"metadata,type:jsonb,default:'{}',notnull," json:"metadata"`
|
||||||
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
||||||
SessionID resolvespec_common.SqlString `bun:"session_id,type:text,notnull," json:"session_id"`
|
SessionID sql_types.SqlString `bun:"session_id,type:text,notnull," json:"session_id"`
|
||||||
Summary resolvespec_common.SqlString `bun:"summary,type:text,nullzero," json:"summary"`
|
Summary sql_types.SqlString `bun:"summary,type:text,nullzero," json:"summary"`
|
||||||
Title resolvespec_common.SqlString `bun:"title,type:text,nullzero," json:"title"`
|
TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero," json:"tenant_key"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
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
|
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicEmbeddings struct {
|
type ModelPublicEmbeddings struct {
|
||||||
bun.BaseModel `bun:"table:public.embeddings,alias:embeddings"`
|
bun.BaseModel `bun:"table:public.embeddings,alias:embeddings"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
||||||
Dim int32 `bun:"dim,type:int,notnull," json:"dim"`
|
Dim int32 `bun:"dim,type:int,notnull," json:"dim"`
|
||||||
Embedding resolvespec_common.SqlVector `bun:"embedding,type:vector,notnull," json:"embedding"`
|
Embedding sql_types.SqlVector `bun:"embedding,type:vector,notnull," json:"embedding"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.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"`
|
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"`
|
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
|
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,39 +3,41 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicLearnings struct {
|
type ModelPublicLearnings struct {
|
||||||
bun.BaseModel `bun:"table:public.learnings,alias:learnings"`
|
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"`
|
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"`
|
Area sql_types.SqlString `bun:"area,type:text,default:'other',notnull," json:"area"`
|
||||||
Category resolvespec_common.SqlString `bun:"category,type:text,default:'insight',notnull," json:"category"`
|
Category sql_types.SqlString `bun:"category,type:text,default:'insight',notnull," json:"category"`
|
||||||
Confidence resolvespec_common.SqlString `bun:"confidence,type:text,default:'hypothesis',notnull," json:"confidence"`
|
Confidence sql_types.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"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Details resolvespec_common.SqlString `bun:"details,type:text,default:'',notnull," json:"details"`
|
Details sql_types.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"`
|
DuplicateOfLearningID sql_types.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"`
|
GUID sql_types.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"`
|
Priority sql_types.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"`
|
||||||
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
ProjectID sql_types.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"`
|
RelatedSkillID sql_types.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"`
|
RelatedThoughtID sql_types.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"`
|
ReviewedAt sql_types.SqlTimeStamp `bun:"reviewed_at,type:timestamptz,nullzero," json:"reviewed_at"`
|
||||||
ReviewedBy resolvespec_common.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
|
ReviewedBy sql_types.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
|
||||||
SourceRef resolvespec_common.SqlString `bun:"source_ref,type:text,nullzero," json:"source_ref"`
|
SourceRef sql_types.SqlString `bun:"source_ref,type:text,nullzero," json:"source_ref"`
|
||||||
SourceType resolvespec_common.SqlString `bun:"source_type,type:text,nullzero," json:"source_type"`
|
SourceType sql_types.SqlString `bun:"source_type,type:text,nullzero," json:"source_type"`
|
||||||
Status resolvespec_common.SqlString `bun:"status,type:text,default:'pending',notnull," json:"status"`
|
Status sql_types.SqlString `bun:"status,type:text,default:'pending',notnull," json:"status"`
|
||||||
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
Summary sql_types.SqlString `bun:"summary,type:text,notnull," json:"summary"`
|
||||||
SupersedesLearningID resolvespec_common.SqlInt64 `bun:"supersedes_learning_id,type:bigint,nullzero," json:"supersedes_learning_id"`
|
SupersedesLearningID sql_types.SqlInt64 `bun:"supersedes_learning_id,type:bigint,nullzero," json:"supersedes_learning_id"`
|
||||||
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero," json:"tenant_key"`
|
||||||
|
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
|
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
|
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
|
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
|
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
|
RelSupersedesLearningID *ModelPublicLearnings `bun:"rel:has-one,join:supersedes_learning_id=id" json:"relsupersedeslearningid,omitempty"` // Has one ModelPublicLearnings
|
||||||
|
RelLearningIDPublicThoughtLearningLinks []*ModelPublicThoughtLearningLinks `bun:"rel:has-many,join:id=learning_id" json:"rellearningidpublicthoughtlearninglinks,omitempty"` // Has many ModelPublicThoughtLearningLinks
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for ModelPublicLearnings
|
// TableName returns the table name for ModelPublicLearnings
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicOauthClients struct {
|
type ModelPublicOauthClients struct {
|
||||||
bun.BaseModel `bun:"table:public.oauth_clients,alias:oauth_clients"`
|
bun.BaseModel `bun:"table:public.oauth_clients,alias:oauth_clients"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
ClientID resolvespec_common.SqlString `bun:"client_id,type:text,notnull," json:"client_id"`
|
ClientID sql_types.SqlString `bun:"client_id,type:text,notnull," json:"client_id"`
|
||||||
ClientName resolvespec_common.SqlString `bun:"client_name,type:text,default:'',notnull," json:"client_name"`
|
ClientName sql_types.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"`
|
CreatedAt sql_types.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"`
|
RedirectUris sql_types.SqlStringArray `bun:"redirect_uris,type:text[],default:'{}',notnull," json:"redirect_uris"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for ModelPublicOauthClients
|
// TableName returns the table name for ModelPublicOauthClients
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicPersonaArc struct {
|
type ModelPublicPersonaArc struct {
|
||||||
bun.BaseModel `bun:"table:public.persona_arc,alias:persona_arc"`
|
bun.BaseModel `bun:"table:public.persona_arc,alias:persona_arc"`
|
||||||
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,pk," json:"persona_id"`
|
PersonaID int64 `bun:"persona_id,type:bigint,pk," json:"persona_id"`
|
||||||
ArcID int64 `bun:"arc_id,type:bigint,notnull," json:"arc_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"`
|
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"`
|
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
|
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
|
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
|
RelPersonaID *ModelPublicAgentPersonas `bun:"rel:has-one,join:persona_id=id" json:"relpersonaid,omitempty"` // Has one ModelPublicAgentPersonas
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicPlanDependencies struct {
|
type ModelPublicPlanDependencies struct {
|
||||||
bun.BaseModel `bun:"table:public.plan_dependencies,alias:plan_dependencies"`
|
bun.BaseModel `bun:"table:public.plan_dependencies,alias:plan_dependencies"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
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"`
|
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"`
|
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
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicPlanGuardrails struct {
|
type ModelPublicPlanGuardrails struct {
|
||||||
bun.BaseModel `bun:"table:public.plan_guardrails,alias:plan_guardrails"`
|
bun.BaseModel `bun:"table:public.plan_guardrails,alias:plan_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"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
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"`
|
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"`
|
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
|
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicPlanRelatedPlans struct {
|
type ModelPublicPlanRelatedPlans struct {
|
||||||
bun.BaseModel `bun:"table:public.plan_related_plans,alias:plan_related_plans"`
|
bun.BaseModel `bun:"table:public.plan_related_plans,alias:plan_related_plans"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
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"`
|
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"`
|
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
|
RelPlanAID *ModelPublicPlans `bun:"rel:has-one,join:plan_a_id=id" json:"relplanaid,omitempty"` // Has one ModelPublicPlans
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicPlanSkills struct {
|
type ModelPublicPlanSkills struct {
|
||||||
bun.BaseModel `bun:"table:public.plan_skills,alias:plan_skills"`
|
bun.BaseModel `bun:"table:public.plan_skills,alias:plan_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"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
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"`
|
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"`
|
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
|
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
|
||||||
|
|||||||
@@ -3,28 +3,29 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicPlans struct {
|
type ModelPublicPlans struct {
|
||||||
bun.BaseModel `bun:"table:public.plans,alias:plans"`
|
bun.BaseModel `bun:"table:public.plans,alias:plans"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CompletedAt resolvespec_common.SqlTimeStamp `bun:"completed_at,type:timestamptz,nullzero," json:"completed_at"`
|
CompletedAt sql_types.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"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||||
DueDate resolvespec_common.SqlTimeStamp `bun:"due_date,type:timestamptz,nullzero," json:"due_date"`
|
DueDate sql_types.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"`
|
GUID sql_types.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"`
|
LastReviewedAt sql_types.SqlTimeStamp `bun:"last_reviewed_at,type:timestamptz,nullzero," json:"last_reviewed_at"`
|
||||||
Owner resolvespec_common.SqlString `bun:"owner,type:text,nullzero," json:"owner"`
|
Owner sql_types.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
|
Priority sql_types.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"`
|
ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
||||||
ReviewedBy resolvespec_common.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
|
ReviewedBy sql_types.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
|
Status sql_types.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"`
|
SupersedesPlanID sql_types.SqlInt64 `bun:"supersedes_plan_id,type:bigint,nullzero," json:"supersedes_plan_id"`
|
||||||
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
Tags sql_types.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||||
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
|
TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero," json:"tenant_key"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
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
|
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
|
RelSupersedesPlanID *ModelPublicPlans `bun:"rel:has-one,join:supersedes_plan_id=id" json:"relsupersedesplanid,omitempty"` // Has one ModelPublicPlans
|
||||||
RelDependsOnPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=depends_on_plan_id" json:"reldependsonplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
|
RelDependsOnPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=depends_on_plan_id" json:"reldependsonplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicProjectGuardrails struct {
|
type ModelPublicProjectGuardrails struct {
|
||||||
bun.BaseModel `bun:"table:public.project_guardrails,alias:project_guardrails"`
|
bun.BaseModel `bun:"table:public.project_guardrails,alias:project_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"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
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"`
|
GuardrailID int64 `bun:"guardrail_id,type:bigint,notnull," json:"guardrail_id"`
|
||||||
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_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
|
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicProjectPersonas struct {
|
type ModelPublicProjectPersonas struct {
|
||||||
bun.BaseModel `bun:"table:public.project_personas,alias:project_personas"`
|
bun.BaseModel `bun:"table:public.project_personas,alias:project_personas"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
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"`
|
IsDefault bool `bun:"is_default,type:boolean,default:false,notnull," json:"is_default"`
|
||||||
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
PersonaID int64 `bun:"persona_id,type:bigint,notnull," json:"persona_id"`
|
||||||
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
|
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicProjectSkills struct {
|
type ModelPublicProjectSkills struct {
|
||||||
bun.BaseModel `bun:"table:public.project_skills,alias:project_skills"`
|
bun.BaseModel `bun:"table:public.project_skills,alias:project_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"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
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"`
|
Override bool `bun:"override,type:boolean,default:false,notnull," json:"override"`
|
||||||
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
|
ProjectID int64 `bun:"project_id,type:bigint,notnull," json:"project_id"`
|
||||||
SkillID int64 `bun:"skill_id,type:bigint,notnull," json:"skill_id"`
|
SkillID int64 `bun:"skill_id,type:bigint,notnull," json:"skill_id"`
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicProjects struct {
|
type ModelPublicProjects struct {
|
||||||
bun.BaseModel `bun:"table:public.projects,alias:projects"`
|
bun.BaseModel `bun:"table:public.projects,alias:projects"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
||||||
Description resolvespec_common.SqlString `bun:"description,type:text,nullzero," json:"description"`
|
Description sql_types.SqlString `bun:"description,type:text,nullzero," json:"description"`
|
||||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
GUID sql_types.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"`
|
LastActiveAt sql_types.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"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull,unique:uidx_projects_tenant_key_name," json:"name"`
|
||||||
ThoughtCount resolvespec_common.SqlInt64 `bun:"thought_count,scanonly" json:"thought_count"`
|
TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero,unique:uidx_projects_tenant_key_name," json:"tenant_key"`
|
||||||
RelProjectIDPublicProjectPersonas []*ModelPublicProjectPersonas `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicprojectpersonas,omitempty"` // Has many ModelPublicProjectPersonas
|
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
|
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
|
RelProjectIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:id=project_id" json:"relprojectidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
|
||||||
|
|||||||
@@ -3,25 +3,26 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicStoredFiles struct {
|
type ModelPublicStoredFiles struct {
|
||||||
bun.BaseModel `bun:"table:public.stored_files,alias:stored_files"`
|
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"`
|
Content []byte `bun:"content,type:bytea,notnull," json:"content"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.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"`
|
Encoding sql_types.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"`
|
GUID sql_types.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"`
|
Kind sql_types.SqlString `bun:"kind,type:text,default:'file',notnull," json:"kind"`
|
||||||
MediaType resolvespec_common.SqlString `bun:"media_type,type:text,notnull," json:"media_type"`
|
MediaType sql_types.SqlString `bun:"media_type,type:text,notnull," json:"media_type"`
|
||||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
Name sql_types.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||||
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
ProjectID sql_types.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
||||||
Sha256 resolvespec_common.SqlString `bun:"sha256,type:text,notnull," json:"sha256"`
|
Sha256 sql_types.SqlString `bun:"sha256,type:text,notnull," json:"sha256"`
|
||||||
SizeBytes int64 `bun:"size_bytes,type:bigint,notnull," json:"size_bytes"`
|
SizeBytes int64 `bun:"size_bytes,type:bigint,notnull," json:"size_bytes"`
|
||||||
ThoughtID resolvespec_common.SqlInt64 `bun:"thought_id,type:bigint,nullzero," json:"thought_id"`
|
TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero," json:"tenant_key"`
|
||||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
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
|
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||||
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
|
RelThoughtID *ModelPublicThoughts `bun:"rel:has-one,join:thought_id=id" json:"relthoughtid,omitempty"` // Has one ModelPublicThoughts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicThoughtLinks struct {
|
type ModelPublicThoughtLinks struct {
|
||||||
bun.BaseModel `bun:"table:public.thought_links,alias:thought_links"`
|
bun.BaseModel `bun:"table:public.thought_links,alias:thought_links"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
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"`
|
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"`
|
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
|
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
|
RelToID *ModelPublicThoughts `bun:"rel:has-one,join:to_id=id" json:"reltoid,omitempty"` // Has one ModelPublicThoughts
|
||||||
|
|||||||
@@ -3,23 +3,25 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicThoughts struct {
|
type ModelPublicThoughts struct {
|
||||||
bun.BaseModel `bun:"table:public.thoughts,alias:thoughts"`
|
bun.BaseModel `bun:"table:public.thoughts,alias:thoughts"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
ArchivedAt resolvespec_common.SqlTimeStamp `bun:"archived_at,type:timestamptz,nullzero," json:"archived_at"`
|
ArchivedAt sql_types.SqlTimeStamp `bun:"archived_at,type:timestamptz,nullzero," json:"archived_at"`
|
||||||
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"`
|
Content sql_types.SqlString `bun:"content,type:text,notnull," json:"content"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
|
CreatedAt sql_types.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"`
|
GUID sql_types.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"`
|
Metadata sql_types.SqlJSONB `bun:"metadata,type:jsonb,default:{}::jsonb,nullzero," json:"metadata"`
|
||||||
ProjectID resolvespec_common.SqlInt64 `bun:"project_id,type:bigint,nullzero," json:"project_id"`
|
ProjectID sql_types.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"`
|
TenantKey sql_types.SqlString `bun:"tenant_key,type:text,nullzero," json:"tenant_key"`
|
||||||
|
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
|
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=id" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
|
||||||
RelFromIDPublicThoughtLinks []*ModelPublicThoughtLinks `bun:"rel:has-many,join:id=from_id" json:"relfromidpublicthoughtlinks,omitempty"` // Has many ModelPublicThoughtLinks
|
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
|
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
|
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
|
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
|
RelRelatedThoughtIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=related_thought_id" json:"relrelatedthoughtidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ package generatedmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
|
sql_types "git.warky.dev/wdevs/relspecgo/pkg/sqltypes"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ModelPublicToolAnnotations struct {
|
type ModelPublicToolAnnotations struct {
|
||||||
bun.BaseModel `bun:"table:public.tool_annotations,alias:tool_annotations"`
|
bun.BaseModel `bun:"table:public.tool_annotations,alias:tool_annotations"`
|
||||||
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
ID sql_types.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
|
||||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
CreatedAt sql_types.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||||
Notes resolvespec_common.SqlString `bun:"notes,type:text,default:'',notnull," json:"notes"`
|
Notes sql_types.SqlString `bun:"notes,type:text,default:'',notnull," json:"notes"`
|
||||||
ToolName resolvespec_common.SqlString `bun:"tool_name,type:text,notnull," json:"tool_name"`
|
ToolName sql_types.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"`
|
UpdatedAt sql_types.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for ModelPublicToolAnnotations
|
// TableName returns the table name for ModelPublicToolAnnotations
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func Normalize(in thoughttypes.ThoughtMetadata, capture config.CaptureConfig) th
|
|||||||
Type: normalizeType(in.Type),
|
Type: normalizeType(in.Type),
|
||||||
Source: normalizeSource(in.Source),
|
Source: normalizeSource(in.Source),
|
||||||
Attachments: normalizeAttachments(in.Attachments),
|
Attachments: normalizeAttachments(in.Attachments),
|
||||||
|
Webhook: normalizeWebhook(in.Webhook),
|
||||||
MetadataStatus: normalizeMetadataStatus(in.MetadataStatus),
|
MetadataStatus: normalizeMetadataStatus(in.MetadataStatus),
|
||||||
MetadataUpdatedAt: strings.TrimSpace(in.MetadataUpdatedAt),
|
MetadataUpdatedAt: strings.TrimSpace(in.MetadataUpdatedAt),
|
||||||
MetadataLastAttemptedAt: strings.TrimSpace(in.MetadataLastAttemptedAt),
|
MetadataLastAttemptedAt: strings.TrimSpace(in.MetadataLastAttemptedAt),
|
||||||
@@ -201,10 +202,31 @@ func Merge(base, patch thoughttypes.ThoughtMetadata, capture config.CaptureConfi
|
|||||||
if len(patch.Attachments) > 0 {
|
if len(patch.Attachments) > 0 {
|
||||||
merged.Attachments = append(append([]thoughttypes.ThoughtAttachment{}, merged.Attachments...), patch.Attachments...)
|
merged.Attachments = append(append([]thoughttypes.ThoughtAttachment{}, merged.Attachments...), patch.Attachments...)
|
||||||
}
|
}
|
||||||
|
if patch.Webhook != nil {
|
||||||
|
merged.Webhook = patch.Webhook
|
||||||
|
}
|
||||||
|
|
||||||
return Normalize(merged, capture)
|
return Normalize(merged, capture)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeWebhook(value *thoughttypes.WebhookMetadata) *thoughttypes.WebhookMetadata {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := &thoughttypes.WebhookMetadata{
|
||||||
|
ReceivedAt: strings.TrimSpace(value.ReceivedAt),
|
||||||
|
IDempotencyKey: strings.TrimSpace(value.IDempotencyKey),
|
||||||
|
ExternalID: strings.TrimSpace(value.ExternalID),
|
||||||
|
}
|
||||||
|
if len(value.SourceMetadata) > 0 {
|
||||||
|
out.SourceMetadata = value.SourceMetadata
|
||||||
|
}
|
||||||
|
if out.ReceivedAt == "" && out.IDempotencyKey == "" && out.ExternalID == "" && len(out.SourceMetadata) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeAttachments(values []thoughttypes.ThoughtAttachment) []thoughttypes.ThoughtAttachment {
|
func normalizeAttachments(values []thoughttypes.ThoughtAttachment) []thoughttypes.ThoughtAttachment {
|
||||||
seen := make(map[string]struct{}, len(values))
|
seen := make(map[string]struct{}, len(values))
|
||||||
result := make([]thoughttypes.ThoughtAttachment, 0, len(values))
|
result := make([]thoughttypes.ThoughtAttachment, 0, len(values))
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ import (
|
|||||||
|
|
||||||
func (db *DB) InsertStoredFile(ctx context.Context, file thoughttypes.StoredFile) (thoughttypes.StoredFile, error) {
|
func (db *DB) InsertStoredFile(ctx context.Context, file thoughttypes.StoredFile) (thoughttypes.StoredFile, error) {
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
insert into stored_files (thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, content)
|
insert into stored_files (thought_id, project_id, tenant_key, name, media_type, kind, encoding, size_bytes, sha256, content)
|
||||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
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
|
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
|
var model generatedmodels.ModelPublicStoredFiles
|
||||||
if err := row.Scan(
|
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) {
|
func (db *DB) GetStoredFile(ctx context.Context, id uuid.UUID) (thoughttypes.StoredFile, error) {
|
||||||
|
args := []any{id}
|
||||||
row := db.pool.QueryRow(ctx, `
|
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
|
select id, guid, thought_id, project_id, name, media_type, kind, encoding, size_bytes, sha256, content, created_at, updated_at
|
||||||
from stored_files
|
from stored_files
|
||||||
where guid = $1
|
where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||||
`, id)
|
|
||||||
|
|
||||||
var model generatedmodels.ModelPublicStoredFiles
|
var model generatedmodels.ModelPublicStoredFiles
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
@@ -77,6 +77,7 @@ func (db *DB) ListStoredFiles(ctx context.Context, filter thoughttypes.StoredFil
|
|||||||
args := make([]any, 0, 4)
|
args := make([]any, 0, 4)
|
||||||
conditions := make([]string, 0, 3)
|
conditions := make([]string, 0, 3)
|
||||||
|
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "tenant_key")
|
||||||
if filter.ThoughtID != nil {
|
if filter.ThoughtID != nil {
|
||||||
args = append(args, *filter.ThoughtID)
|
args = append(args, *filter.ThoughtID)
|
||||||
conditions = append(conditions, fmt.Sprintf("thought_id = $%d", len(args)))
|
conditions = append(conditions, fmt.Sprintf("thought_id = $%d", len(args)))
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import (
|
|||||||
|
|
||||||
func (db *DB) CreateProject(ctx context.Context, name, description string) (thoughttypes.Project, error) {
|
func (db *DB) CreateProject(ctx context.Context, name, description string) (thoughttypes.Project, error) {
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
insert into projects (name, description)
|
insert into projects (name, description, tenant_key)
|
||||||
values ($1, $2)
|
values ($1, $2, $3)
|
||||||
returning id, guid, name, description, created_at, last_active_at
|
returning id, guid, name, description, created_at, last_active_at
|
||||||
`, name, description)
|
`, name, description, tenantKeyPtr(ctx))
|
||||||
|
|
||||||
var model generatedmodels.ModelPublicProjects
|
var model generatedmodels.ModelPublicProjects
|
||||||
if err := row.Scan(&model.ID, &model.GUID, &model.Name, &model.Description, &model.CreatedAt, &model.LastActiveAt); err != nil {
|
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) {
|
func (db *DB) getProjectByGUID(ctx context.Context, id uuid.UUID) (thoughttypes.Project, error) {
|
||||||
|
args := []any{id}
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
select id, guid, name, description, created_at, last_active_at
|
select id, guid, name, description, created_at, last_active_at
|
||||||
from projects
|
from projects
|
||||||
where guid = $1
|
where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||||
`, id)
|
|
||||||
return scanProject(row)
|
return scanProject(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) getProjectByName(ctx context.Context, name string) (thoughttypes.Project, error) {
|
func (db *DB) getProjectByName(ctx context.Context, name string) (thoughttypes.Project, error) {
|
||||||
|
args := []any{name}
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
select id, guid, name, description, created_at, last_active_at
|
select id, guid, name, description, created_at, last_active_at
|
||||||
from projects
|
from projects
|
||||||
where name = $1
|
where name = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||||
`, name)
|
|
||||||
return scanProject(row)
|
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) {
|
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_key = $1"
|
||||||
|
}
|
||||||
rows, err := db.pool.Query(ctx, `
|
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
|
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
|
from projects p
|
||||||
left join thoughts t on t.project_id = p.id and t.archived_at is null
|
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
|
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
|
order by p.last_active_at desc, p.created_at desc
|
||||||
`)
|
`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list projects: %w", err)
|
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 {
|
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_key"), args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("touch project: %w", err)
|
return fmt.Errorf("touch project: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
+42
-15
@@ -31,10 +31,10 @@ func (db *DB) InsertThought(ctx context.Context, thought thoughttypes.Thought, e
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
row := tx.QueryRow(ctx, `
|
row := tx.QueryRow(ctx, `
|
||||||
insert into thoughts (content, metadata, project_id)
|
insert into thoughts (content, metadata, project_id, tenant_key)
|
||||||
values ($1, $2::jsonb, $3)
|
values ($1, $2::jsonb, $3, $4)
|
||||||
returning id, guid, created_at, updated_at
|
returning id, guid, created_at, updated_at
|
||||||
`, thought.Content, metadata, thought.ProjectID)
|
`, thought.Content, metadata, thought.ProjectID, tenantKeyPtr(ctx))
|
||||||
|
|
||||||
created := thought
|
created := thought
|
||||||
created.Embedding = nil
|
created.Embedding = nil
|
||||||
@@ -68,6 +68,22 @@ func (db *DB) InsertThought(ctx context.Context, thought thoughttypes.Thought, e
|
|||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetThoughtByWebhookIDempotencyKey(ctx context.Context, key string) (thoughttypes.Thought, error) {
|
||||||
|
row := db.pool.QueryRow(ctx, `
|
||||||
|
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
|
||||||
|
from thoughts
|
||||||
|
where metadata->'webhook'->>'idempotency_key' = $1
|
||||||
|
order by created_at desc
|
||||||
|
limit 1
|
||||||
|
`, strings.TrimSpace(key))
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return thoughttypes.Thought{}, err
|
||||||
|
}
|
||||||
|
return thoughtFromModel(model)
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) SearchThoughts(ctx context.Context, embedding []float32, embeddingModel string, threshold float64, limit int, filter map[string]any) ([]thoughttypes.SearchResult, error) {
|
func (db *DB) SearchThoughts(ctx context.Context, embedding []float32, embeddingModel string, threshold float64, limit int, filter map[string]any) ([]thoughttypes.SearchResult, error) {
|
||||||
filterJSON, err := json.Marshal(filter)
|
filterJSON, err := json.Marshal(filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,6 +123,7 @@ func (db *DB) ListThoughts(ctx context.Context, filter thoughttypes.ListFilter)
|
|||||||
args := make([]any, 0, 6)
|
args := make([]any, 0, 6)
|
||||||
conditions := []string{}
|
conditions := []string{}
|
||||||
|
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "tenant_key")
|
||||||
if !filter.IncludeArchived {
|
if !filter.IncludeArchived {
|
||||||
conditions = append(conditions, "archived_at is null")
|
conditions = append(conditions, "archived_at is null")
|
||||||
}
|
}
|
||||||
@@ -170,11 +187,14 @@ func (db *DB) ListThoughts(ctx context.Context, filter thoughttypes.ListFilter)
|
|||||||
|
|
||||||
func (db *DB) Stats(ctx context.Context) (thoughttypes.ThoughtStats, error) {
|
func (db *DB) Stats(ctx context.Context) (thoughttypes.ThoughtStats, error) {
|
||||||
var total int
|
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_key")
|
||||||
|
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)
|
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 {
|
if err != nil {
|
||||||
return thoughttypes.ThoughtStats{}, fmt.Errorf("query stats metadata: %w", err)
|
return thoughttypes.ThoughtStats{}, fmt.Errorf("query stats metadata: %w", err)
|
||||||
}
|
}
|
||||||
@@ -217,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) {
|
func (db *DB) GetThought(ctx context.Context, id uuid.UUID) (thoughttypes.Thought, error) {
|
||||||
|
args := []any{id}
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
|
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
|
||||||
from thoughts
|
from thoughts
|
||||||
where guid = $1
|
where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||||
`, id)
|
|
||||||
|
|
||||||
var model generatedmodels.ModelPublicThoughts
|
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 {
|
if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||||
@@ -240,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) {
|
func (db *DB) GetThoughtByID(ctx context.Context, id int64) (thoughttypes.Thought, error) {
|
||||||
|
args := []any{id}
|
||||||
row := db.pool.QueryRow(ctx, `
|
row := db.pool.QueryRow(ctx, `
|
||||||
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
|
select id, guid, content, metadata, project_id, archived_at, created_at, updated_at
|
||||||
from thoughts
|
from thoughts
|
||||||
where id = $1
|
where id = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||||
`, id)
|
|
||||||
|
|
||||||
var model generatedmodels.ModelPublicThoughts
|
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 {
|
if err := row.Scan(&model.ID, &model.GUID, &model.Content, &model.Metadata, &model.ProjectID, &model.ArchivedAt, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||||
@@ -276,14 +296,14 @@ func (db *DB) UpdateThought(ctx context.Context, id uuid.UUID, content string, e
|
|||||||
_ = tx.Rollback(ctx)
|
_ = tx.Rollback(ctx)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
args := []any{id, content, metadataBytes, projectID}
|
||||||
tag, err := tx.Exec(ctx, `
|
tag, err := tx.Exec(ctx, `
|
||||||
update thoughts
|
update thoughts
|
||||||
set content = $2,
|
set content = $2,
|
||||||
metadata = $3::jsonb,
|
metadata = $3::jsonb,
|
||||||
project_id = $4,
|
project_id = $4,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where guid = $1
|
where guid = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||||
`, id, content, metadataBytes, projectID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return thoughttypes.Thought{}, fmt.Errorf("update thought: %w", err)
|
return thoughttypes.Thought{}, fmt.Errorf("update thought: %w", err)
|
||||||
}
|
}
|
||||||
@@ -317,12 +337,12 @@ func (db *DB) UpdateThoughtMetadata(ctx context.Context, id int64, metadata thou
|
|||||||
return thoughttypes.Thought{}, fmt.Errorf("marshal updated metadata: %w", err)
|
return thoughttypes.Thought{}, fmt.Errorf("marshal updated metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
args := []any{id, metadataBytes}
|
||||||
tag, err := db.pool.Exec(ctx, `
|
tag, err := db.pool.Exec(ctx, `
|
||||||
update thoughts
|
update thoughts
|
||||||
set metadata = $2::jsonb,
|
set metadata = $2::jsonb,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where id = $1
|
where id = $1`+tenantSQL(ctx, &args, "tenant_key"), args...)
|
||||||
`, id, metadataBytes)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return thoughttypes.Thought{}, fmt.Errorf("update thought metadata: %w", err)
|
return thoughttypes.Thought{}, fmt.Errorf("update thought metadata: %w", err)
|
||||||
}
|
}
|
||||||
@@ -334,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 {
|
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_key"), args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("delete thought: %w", err)
|
return fmt.Errorf("delete thought: %w", err)
|
||||||
}
|
}
|
||||||
@@ -345,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 {
|
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_key"), args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("archive thought: %w", err)
|
return fmt.Errorf("archive thought: %w", err)
|
||||||
}
|
}
|
||||||
@@ -424,6 +446,7 @@ func (db *DB) SearchSimilarThoughts(ctx context.Context, embedding []float32, em
|
|||||||
"1 - (e.embedding <=> $1) > $2",
|
"1 - (e.embedding <=> $1) > $2",
|
||||||
"e.model = $3",
|
"e.model = $3",
|
||||||
}
|
}
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
|
||||||
if projectID != nil {
|
if projectID != nil {
|
||||||
args = append(args, *projectID)
|
args = append(args, *projectID)
|
||||||
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
|
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
|
||||||
@@ -472,6 +495,7 @@ func (db *DB) HasEmbeddingsForModel(ctx context.Context, model string, projectID
|
|||||||
"e.model = $1",
|
"e.model = $1",
|
||||||
"t.archived_at is null",
|
"t.archived_at is null",
|
||||||
}
|
}
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
|
||||||
if projectID != nil {
|
if projectID != nil {
|
||||||
args = append(args, *projectID)
|
args = append(args, *projectID)
|
||||||
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
|
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
|
||||||
@@ -490,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) {
|
func (db *DB) ListThoughtsMissingEmbedding(ctx context.Context, model string, limit int, projectID *int64, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
|
||||||
args := []any{model}
|
args := []any{model}
|
||||||
conditions := []string{"e.id is null"}
|
conditions := []string{"e.id is null"}
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
|
||||||
|
|
||||||
if !includeArchived {
|
if !includeArchived {
|
||||||
conditions = append(conditions, "t.archived_at is null")
|
conditions = append(conditions, "t.archived_at is null")
|
||||||
@@ -539,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) {
|
func (db *DB) ListThoughtsForMetadataReparse(ctx context.Context, limit int, projectID *int64, includeArchived bool, olderThanDays int) ([]thoughttypes.Thought, error) {
|
||||||
args := make([]any, 0, 3)
|
args := make([]any, 0, 3)
|
||||||
conditions := make([]string, 0, 4)
|
conditions := make([]string, 0, 4)
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "tenant_key")
|
||||||
|
|
||||||
if !includeArchived {
|
if !includeArchived {
|
||||||
conditions = append(conditions, "archived_at is null")
|
conditions = append(conditions, "archived_at is null")
|
||||||
@@ -608,6 +634,7 @@ func (db *DB) SearchThoughtsText(ctx context.Context, query string, limit int, p
|
|||||||
"t.archived_at is null",
|
"t.archived_at is null",
|
||||||
"(to_tsvector('simple', t.content) || to_tsvector('simple', coalesce(p.name, ''))) @@ websearch_to_tsquery('simple', $1)",
|
"(to_tsvector('simple', t.content) || to_tsvector('simple', coalesce(p.name, ''))) @@ websearch_to_tsquery('simple', $1)",
|
||||||
}
|
}
|
||||||
|
addTenantCondition(ctx, &args, &conditions, "t.tenant_key")
|
||||||
if projectID != nil {
|
if projectID != nil {
|
||||||
args = append(args, *projectID)
|
args = append(args, *projectID)
|
||||||
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
|
conditions = append(conditions, fmt.Sprintf("t.project_id = $%d", len(args)))
|
||||||
|
|||||||
@@ -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 != ""
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ type ProjectContextOutput struct {
|
|||||||
Project thoughttypes.Project `json:"project"`
|
Project thoughttypes.Project `json:"project"`
|
||||||
Context string `json:"context"`
|
Context string `json:"context"`
|
||||||
Items []ContextItem `json:"items"`
|
Items []ContextItem `json:"items"`
|
||||||
|
RetrievalMode string `json:"retrieval_mode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewContextTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *ContextTool {
|
func NewContextTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *ContextTool {
|
||||||
@@ -70,12 +71,14 @@ func (t *ContextTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in P
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var retrievalMode string
|
||||||
query := strings.TrimSpace(in.Query)
|
query := strings.TrimSpace(in.Query)
|
||||||
if query != "" {
|
if query != "" {
|
||||||
semantic, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, &project.NumericID, nil)
|
semantic, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, &project.NumericID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ProjectContextOutput{}, err
|
return nil, ProjectContextOutput{}, err
|
||||||
}
|
}
|
||||||
|
retrievalMode = mode
|
||||||
for _, result := range semantic {
|
for _, result := range semantic {
|
||||||
key := fmt.Sprint(result.ID)
|
key := fmt.Sprint(result.ID)
|
||||||
if _, ok := seen[key]; ok {
|
if _, ok := seen[key]; ok {
|
||||||
@@ -103,5 +106,6 @@ func (t *ContextTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in P
|
|||||||
Project: *project,
|
Project: *project,
|
||||||
Context: contextBlock,
|
Context: contextBlock,
|
||||||
Items: items,
|
Items: items,
|
||||||
|
RetrievalMode: retrievalMode,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type RelatedThought struct {
|
|||||||
|
|
||||||
type RelatedOutput struct {
|
type RelatedOutput struct {
|
||||||
Related []RelatedThought `json:"related"`
|
Related []RelatedThought `json:"related"`
|
||||||
|
RetrievalMode string `json:"retrieval_mode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLinksTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig) *LinksTool {
|
func NewLinksTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig) *LinksTool {
|
||||||
@@ -117,11 +118,13 @@ func (t *LinksTool) Related(ctx context.Context, _ *mcp.CallToolRequest, in Rela
|
|||||||
includeSemantic = *in.IncludeSemantic
|
includeSemantic = *in.IncludeSemantic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var retrievalMode string
|
||||||
if includeSemantic {
|
if includeSemantic {
|
||||||
semantic, err := semanticSearch(ctx, t.store, t.embeddings, t.search, thought.Content, t.search.DefaultLimit, t.search.DefaultThreshold, thought.ProjectID, &thought.GUID)
|
semantic, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, thought.Content, t.search.DefaultLimit, t.search.DefaultThreshold, thought.ProjectID, &thought.GUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, RelatedOutput{}, err
|
return nil, RelatedOutput{}, err
|
||||||
}
|
}
|
||||||
|
retrievalMode = mode
|
||||||
for _, item := range semantic {
|
for _, item := range semantic {
|
||||||
key := fmt.Sprint(item.ID)
|
key := fmt.Sprint(item.ID)
|
||||||
if _, ok := seen[key]; ok {
|
if _, ok := seen[key]; ok {
|
||||||
@@ -138,5 +141,5 @@ func (t *LinksTool) Related(ctx context.Context, _ *mcp.CallToolRequest, in Rela
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, RelatedOutput{Related: related}, nil
|
return nil, RelatedOutput{Related: related, RetrievalMode: retrievalMode}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type RecallInput struct {
|
|||||||
type RecallOutput struct {
|
type RecallOutput struct {
|
||||||
Context string `json:"context"`
|
Context string `json:"context"`
|
||||||
Items []ContextItem `json:"items"`
|
Items []ContextItem `json:"items"`
|
||||||
|
RetrievalMode string `json:"retrieval_mode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRecallTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *RecallTool {
|
func NewRecallTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *RecallTool {
|
||||||
@@ -53,7 +54,7 @@ func (t *RecallTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Re
|
|||||||
projectID = &project.NumericID
|
projectID = &project.NumericID
|
||||||
}
|
}
|
||||||
|
|
||||||
semantic, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
|
semantic, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, RecallOutput{}, err
|
return nil, RecallOutput{}, err
|
||||||
}
|
}
|
||||||
@@ -102,5 +103,6 @@ func (t *RecallTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Re
|
|||||||
return nil, RecallOutput{
|
return nil, RecallOutput{
|
||||||
Context: formatContextBlock(header, lines),
|
Context: formatContextBlock(header, lines),
|
||||||
Items: items,
|
Items: items,
|
||||||
|
RetrievalMode: mode,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,16 @@ import (
|
|||||||
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
RetrievalModeSemantic = "semantic"
|
||||||
|
RetrievalModeText = "text"
|
||||||
|
)
|
||||||
|
|
||||||
// semanticSearch runs vector similarity search if embeddings exist for the
|
// semanticSearch runs vector similarity search if embeddings exist for the
|
||||||
// primary embedding model in the given scope, otherwise falls back to Postgres
|
// primary embedding model in the given scope, otherwise falls back to Postgres
|
||||||
// full-text search. Search always uses the primary model so query vectors
|
// full-text search. Search always uses the primary model so query vectors
|
||||||
// match rows stored under the primary model name.
|
// match rows stored under the primary model name.
|
||||||
|
// It returns the results and the retrieval mode used ("semantic" or "text").
|
||||||
func semanticSearch(
|
func semanticSearch(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
db *store.DB,
|
db *store.DB,
|
||||||
@@ -25,20 +31,22 @@ func semanticSearch(
|
|||||||
threshold float64,
|
threshold float64,
|
||||||
projectID *int64,
|
projectID *int64,
|
||||||
excludeID *uuid.UUID,
|
excludeID *uuid.UUID,
|
||||||
) ([]thoughttypes.SearchResult, error) {
|
) ([]thoughttypes.SearchResult, string, error) {
|
||||||
model := embeddings.PrimaryModel()
|
model := embeddings.PrimaryModel()
|
||||||
hasEmbeddings, err := db.HasEmbeddingsForModel(ctx, model, projectID)
|
hasEmbeddings, err := db.HasEmbeddingsForModel(ctx, model, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if hasEmbeddings {
|
if hasEmbeddings {
|
||||||
embedding, err := embeddings.EmbedPrimary(ctx, query)
|
embedding, err := embeddings.EmbedPrimary(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
return db.SearchSimilarThoughts(ctx, embedding, model, threshold, limit, projectID, excludeID)
|
results, err := db.SearchSimilarThoughts(ctx, embedding, model, threshold, limit, projectID, excludeID)
|
||||||
|
return results, RetrievalModeSemantic, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.SearchThoughtsText(ctx, query, limit, projectID, excludeID)
|
results, err := db.SearchThoughtsText(ctx, query, limit, projectID, excludeID)
|
||||||
|
return results, RetrievalModeText, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRetrievalModeConstants(t *testing.T) {
|
||||||
|
if RetrievalModeSemantic != "semantic" {
|
||||||
|
t.Fatalf("RetrievalModeSemantic = %q, want %q", RetrievalModeSemantic, "semantic")
|
||||||
|
}
|
||||||
|
if RetrievalModeText != "text" {
|
||||||
|
t.Fatalf("RetrievalModeText = %q, want %q", RetrievalModeText, "text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchOutputIncludesRetrievalMode(t *testing.T) {
|
||||||
|
out := SearchOutput{RetrievalMode: RetrievalModeSemantic}
|
||||||
|
if out.RetrievalMode != RetrievalModeSemantic {
|
||||||
|
t.Fatalf("SearchOutput.RetrievalMode = %q, want %q", out.RetrievalMode, RetrievalModeSemantic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRelatedOutputIncludesRetrievalMode(t *testing.T) {
|
||||||
|
out := RelatedOutput{RetrievalMode: RetrievalModeText}
|
||||||
|
if out.RetrievalMode != RetrievalModeText {
|
||||||
|
t.Fatalf("RelatedOutput.RetrievalMode = %q, want %q", out.RetrievalMode, RetrievalModeText)
|
||||||
|
}
|
||||||
|
// retrieval_mode is omitempty — empty string means semantic search was skipped
|
||||||
|
out2 := RelatedOutput{}
|
||||||
|
if out2.RetrievalMode != "" {
|
||||||
|
t.Fatalf("RelatedOutput.RetrievalMode = %q, want empty when include_semantic is false", out2.RetrievalMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummarizeOutputIncludesRetrievalModeOnlyWhenQueryUsed(t *testing.T) {
|
||||||
|
withQuery := SummarizeOutput{Summary: "s", Count: 1, RetrievalMode: RetrievalModeSemantic}
|
||||||
|
if withQuery.RetrievalMode != RetrievalModeSemantic {
|
||||||
|
t.Fatalf("SummarizeOutput.RetrievalMode = %q, want %q", withQuery.RetrievalMode, RetrievalModeSemantic)
|
||||||
|
}
|
||||||
|
withoutQuery := SummarizeOutput{Summary: "s", Count: 1}
|
||||||
|
if withoutQuery.RetrievalMode != "" {
|
||||||
|
t.Fatalf("SummarizeOutput.RetrievalMode = %q, want empty when no query", withoutQuery.RetrievalMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectContextOutputIncludesRetrievalModeOnlyWhenQueryUsed(t *testing.T) {
|
||||||
|
withQuery := ProjectContextOutput{RetrievalMode: RetrievalModeText}
|
||||||
|
if withQuery.RetrievalMode != RetrievalModeText {
|
||||||
|
t.Fatalf("ProjectContextOutput.RetrievalMode = %q, want %q", withQuery.RetrievalMode, RetrievalModeText)
|
||||||
|
}
|
||||||
|
withoutQuery := ProjectContextOutput{}
|
||||||
|
if withoutQuery.RetrievalMode != "" {
|
||||||
|
t.Fatalf("ProjectContextOutput.RetrievalMode = %q, want empty when no query", withoutQuery.RetrievalMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecallOutputIncludesRetrievalMode(t *testing.T) {
|
||||||
|
out := RecallOutput{RetrievalMode: RetrievalModeSemantic}
|
||||||
|
if out.RetrievalMode != RetrievalModeSemantic {
|
||||||
|
t.Fatalf("RecallOutput.RetrievalMode = %q, want %q", out.RetrievalMode, RetrievalModeSemantic)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ type SearchInput struct {
|
|||||||
|
|
||||||
type SearchOutput struct {
|
type SearchOutput struct {
|
||||||
Results []thoughttypes.SearchResult `json:"results"`
|
Results []thoughttypes.SearchResult `json:"results"`
|
||||||
|
RetrievalMode string `json:"retrieval_mode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSearchTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *SearchTool {
|
func NewSearchTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *SearchTool {
|
||||||
@@ -55,10 +56,10 @@ func (t *SearchTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Se
|
|||||||
_ = t.store.TouchProject(ctx, project.NumericID)
|
_ = t.store.TouchProject(ctx, project.NumericID)
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, threshold, projectID, nil)
|
results, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, threshold, projectID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, SearchOutput{}, err
|
return nil, SearchOutput{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, SearchOutput{Results: results}, nil
|
return nil, SearchOutput{Results: results, RetrievalMode: mode}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type SummarizeInput struct {
|
|||||||
type SummarizeOutput struct {
|
type SummarizeOutput struct {
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
|
RetrievalMode string `json:"retrieval_mode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSummarizeTool(db *store.DB, embeddings *ai.EmbeddingRunner, metadata *ai.MetadataRunner, search config.SearchConfig, sessions *session.ActiveProjects) *SummarizeTool {
|
func NewSummarizeTool(db *store.DB, embeddings *ai.EmbeddingRunner, metadata *ai.MetadataRunner, search config.SearchConfig, sessions *session.ActiveProjects) *SummarizeTool {
|
||||||
@@ -47,15 +48,17 @@ func (t *SummarizeTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in
|
|||||||
lines := make([]string, 0, limit)
|
lines := make([]string, 0, limit)
|
||||||
count := 0
|
count := 0
|
||||||
|
|
||||||
|
var retrievalMode string
|
||||||
if query != "" {
|
if query != "" {
|
||||||
var projectID *int64
|
var projectID *int64
|
||||||
if project != nil {
|
if project != nil {
|
||||||
projectID = &project.NumericID
|
projectID = &project.NumericID
|
||||||
}
|
}
|
||||||
results, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
|
results, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, SummarizeOutput{}, err
|
return nil, SummarizeOutput{}, err
|
||||||
}
|
}
|
||||||
|
retrievalMode = mode
|
||||||
for i, result := range results {
|
for i, result := range results {
|
||||||
lines = append(lines, thoughtContextLine(i, result.Content, result.Metadata, result.Similarity))
|
lines = append(lines, thoughtContextLine(i, result.Content, result.Metadata, result.Similarity))
|
||||||
}
|
}
|
||||||
@@ -85,5 +88,5 @@ func (t *SummarizeTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in
|
|||||||
_ = t.store.TouchProject(ctx, project.NumericID)
|
_ = t.store.TouchProject(ctx, project.NumericID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, SummarizeOutput{Summary: summary, Count: count}, nil
|
return nil, SummarizeOutput{Summary: summary, Count: count, RetrievalMode: retrievalMode}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,20 @@ type ThoughtMetadata struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Attachments []ThoughtAttachment `json:"attachments,omitempty"`
|
Attachments []ThoughtAttachment `json:"attachments,omitempty"`
|
||||||
|
Webhook *WebhookMetadata `json:"webhook,omitempty"`
|
||||||
MetadataStatus string `json:"metadata_status,omitempty"`
|
MetadataStatus string `json:"metadata_status,omitempty"`
|
||||||
MetadataUpdatedAt string `json:"metadata_updated_at,omitempty"`
|
MetadataUpdatedAt string `json:"metadata_updated_at,omitempty"`
|
||||||
MetadataLastAttemptedAt string `json:"metadata_last_attempted_at,omitempty"`
|
MetadataLastAttemptedAt string `json:"metadata_last_attempted_at,omitempty"`
|
||||||
MetadataError string `json:"metadata_error,omitempty"`
|
MetadataError string `json:"metadata_error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WebhookMetadata struct {
|
||||||
|
ReceivedAt string `json:"received_at"`
|
||||||
|
IDempotencyKey string `json:"idempotency_key,omitempty"`
|
||||||
|
ExternalID string `json:"external_id,omitempty"`
|
||||||
|
SourceMetadata map[string]any `json:"source_metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ThoughtAttachment struct {
|
type ThoughtAttachment struct {
|
||||||
FileID uuid.UUID `json:"file_id"`
|
FileID uuid.UUID `json:"file_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ CREATE SEQUENCE IF NOT EXISTS public.identity_arc_stage_parts_id
|
|||||||
START 1
|
START 1
|
||||||
CACHE 1;
|
CACHE 1;
|
||||||
|
|
||||||
CREATE SEQUENCE IF NOT EXISTS public.identity_persona_arc_persona_id
|
CREATE SEQUENCE IF NOT EXISTS public.identity_persona_arc_id
|
||||||
INCREMENT 1
|
INCREMENT 1
|
||||||
MINVALUE 1
|
MINVALUE 1
|
||||||
MAXVALUE 9223372036854775807
|
MAXVALUE 9223372036854775807
|
||||||
@@ -110,6 +110,13 @@ CREATE SEQUENCE IF NOT EXISTS public.identity_thought_links_id
|
|||||||
START 1
|
START 1
|
||||||
CACHE 1;
|
CACHE 1;
|
||||||
|
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS public.identity_thought_learning_links_id
|
||||||
|
INCREMENT 1
|
||||||
|
MINVALUE 1
|
||||||
|
MAXVALUE 9223372036854775807
|
||||||
|
START 1
|
||||||
|
CACHE 1;
|
||||||
|
|
||||||
CREATE SEQUENCE IF NOT EXISTS public.identity_embeddings_id
|
CREATE SEQUENCE IF NOT EXISTS public.identity_embeddings_id
|
||||||
INCREMENT 1
|
INCREMENT 1
|
||||||
MINVALUE 1
|
MINVALUE 1
|
||||||
@@ -332,6 +339,7 @@ CREATE TABLE IF NOT EXISTS public.thoughts (
|
|||||||
id bigserial NOT NULL,
|
id bigserial NOT NULL,
|
||||||
metadata jsonb DEFAULT '{}'::jsonb,
|
metadata jsonb DEFAULT '{}'::jsonb,
|
||||||
project_id bigint,
|
project_id bigint,
|
||||||
|
tenant_key text,
|
||||||
updated_at timestamptz DEFAULT now()
|
updated_at timestamptz DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -341,7 +349,8 @@ CREATE TABLE IF NOT EXISTS public.projects (
|
|||||||
guid uuid NOT NULL DEFAULT gen_random_uuid(),
|
guid uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
id bigserial NOT NULL,
|
id bigserial NOT NULL,
|
||||||
last_active_at timestamptz DEFAULT now(),
|
last_active_at timestamptz DEFAULT now(),
|
||||||
name text NOT NULL
|
name text NOT NULL,
|
||||||
|
tenant_key text
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.thought_links (
|
CREATE TABLE IF NOT EXISTS public.thought_links (
|
||||||
@@ -352,6 +361,14 @@ CREATE TABLE IF NOT EXISTS public.thought_links (
|
|||||||
to_id bigint NOT NULL
|
to_id bigint NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.thought_learning_links (
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
id bigserial NOT NULL,
|
||||||
|
learning_id bigint NOT NULL,
|
||||||
|
relation text NOT NULL DEFAULT 'source',
|
||||||
|
thought_id bigint NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.embeddings (
|
CREATE TABLE IF NOT EXISTS public.embeddings (
|
||||||
created_at timestamptz DEFAULT now(),
|
created_at timestamptz DEFAULT now(),
|
||||||
dim integer NOT NULL,
|
dim integer NOT NULL,
|
||||||
@@ -375,6 +392,7 @@ CREATE TABLE IF NOT EXISTS public.stored_files (
|
|||||||
project_id bigint,
|
project_id bigint,
|
||||||
sha256 text NOT NULL,
|
sha256 text NOT NULL,
|
||||||
size_bytes bigint NOT NULL,
|
size_bytes bigint NOT NULL,
|
||||||
|
tenant_key text,
|
||||||
thought_id bigint,
|
thought_id bigint,
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
@@ -390,6 +408,7 @@ CREATE TABLE IF NOT EXISTS public.chat_histories (
|
|||||||
project_id bigint,
|
project_id bigint,
|
||||||
session_id text NOT NULL,
|
session_id text NOT NULL,
|
||||||
summary text,
|
summary text,
|
||||||
|
tenant_key text,
|
||||||
title text,
|
title text,
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
@@ -424,6 +443,7 @@ CREATE TABLE IF NOT EXISTS public.learnings (
|
|||||||
summary text NOT NULL,
|
summary text NOT NULL,
|
||||||
supersedes_learning_id bigint,
|
supersedes_learning_id bigint,
|
||||||
tags text[] NOT NULL DEFAULT '{}',
|
tags text[] NOT NULL DEFAULT '{}',
|
||||||
|
tenant_key text,
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -450,6 +470,7 @@ CREATE TABLE IF NOT EXISTS public.plans (
|
|||||||
status text NOT NULL DEFAULT 'draft',
|
status text NOT NULL DEFAULT 'draft',
|
||||||
supersedes_plan_id bigint,
|
supersedes_plan_id bigint,
|
||||||
tags text[] NOT NULL DEFAULT '{}',
|
tags text[] NOT NULL DEFAULT '{}',
|
||||||
|
tenant_key text,
|
||||||
title text NOT NULL,
|
title text NOT NULL,
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
@@ -1552,6 +1573,19 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'thoughts'
|
||||||
|
AND column_name = 'tenant_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.thoughts ADD COLUMN tenant_key text;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
@@ -1643,6 +1677,19 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'projects'
|
||||||
|
AND column_name = 'tenant_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.projects ADD COLUMN tenant_key text;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
@@ -1708,6 +1755,71 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'thought_learning_links'
|
||||||
|
AND column_name = 'created_at'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.thought_learning_links ADD COLUMN created_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'thought_learning_links'
|
||||||
|
AND column_name = 'id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.thought_learning_links ADD COLUMN id bigserial NOT NULL;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'thought_learning_links'
|
||||||
|
AND column_name = 'learning_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.thought_learning_links ADD COLUMN learning_id bigint NOT NULL;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'thought_learning_links'
|
||||||
|
AND column_name = 'relation'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.thought_learning_links ADD COLUMN relation text NOT NULL DEFAULT 'source';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'thought_learning_links'
|
||||||
|
AND column_name = 'thought_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.thought_learning_links ADD COLUMN thought_id bigint NOT NULL;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
@@ -1955,6 +2067,19 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'stored_files'
|
||||||
|
AND column_name = 'tenant_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.stored_files ADD COLUMN tenant_key text;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
@@ -2111,6 +2236,19 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'chat_histories'
|
||||||
|
AND column_name = 'tenant_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.chat_histories ADD COLUMN tenant_key text;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
@@ -2475,6 +2613,19 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'learnings'
|
||||||
|
AND column_name = 'tenant_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.learnings ADD COLUMN tenant_key text;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
@@ -2735,6 +2886,19 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'plans'
|
||||||
|
AND column_name = 'tenant_key'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.plans ADD COLUMN tenant_key text;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
@@ -5177,6 +5341,29 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
current_type text;
|
||||||
|
BEGIN
|
||||||
|
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
|
||||||
|
INTO current_type
|
||||||
|
FROM pg_attribute a
|
||||||
|
JOIN pg_class t ON t.oid = a.attrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND t.relname = 'thoughts'
|
||||||
|
AND a.attname = 'tenant_key'
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped;
|
||||||
|
|
||||||
|
IF current_type IS NOT NULL
|
||||||
|
AND current_type <> ALL(ARRAY['text']) THEN
|
||||||
|
ALTER TABLE public.thoughts
|
||||||
|
ALTER COLUMN tenant_key TYPE text USING tenant_key::text;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
current_type text;
|
current_type text;
|
||||||
@@ -5338,6 +5525,29 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
current_type text;
|
||||||
|
BEGIN
|
||||||
|
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
|
||||||
|
INTO current_type
|
||||||
|
FROM pg_attribute a
|
||||||
|
JOIN pg_class t ON t.oid = a.attrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND t.relname = 'projects'
|
||||||
|
AND a.attname = 'tenant_key'
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped;
|
||||||
|
|
||||||
|
IF current_type IS NOT NULL
|
||||||
|
AND current_type <> ALL(ARRAY['text']) THEN
|
||||||
|
ALTER TABLE public.projects
|
||||||
|
ALTER COLUMN tenant_key TYPE text USING tenant_key::text;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
current_type text;
|
current_type text;
|
||||||
@@ -5453,6 +5663,121 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
current_type text;
|
||||||
|
BEGIN
|
||||||
|
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
|
||||||
|
INTO current_type
|
||||||
|
FROM pg_attribute a
|
||||||
|
JOIN pg_class t ON t.oid = a.attrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND t.relname = 'thought_learning_links'
|
||||||
|
AND a.attname = 'created_at'
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped;
|
||||||
|
|
||||||
|
IF current_type IS NOT NULL
|
||||||
|
AND current_type <> ALL(ARRAY['timestamptz', 'timestamp with time zone']) THEN
|
||||||
|
ALTER TABLE public.thought_learning_links
|
||||||
|
ALTER COLUMN created_at TYPE timestamptz USING created_at::timestamptz;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
current_type text;
|
||||||
|
BEGIN
|
||||||
|
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
|
||||||
|
INTO current_type
|
||||||
|
FROM pg_attribute a
|
||||||
|
JOIN pg_class t ON t.oid = a.attrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND t.relname = 'thought_learning_links'
|
||||||
|
AND a.attname = 'id'
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped;
|
||||||
|
|
||||||
|
IF current_type IS NOT NULL
|
||||||
|
AND current_type <> ALL(ARRAY['bigint']) THEN
|
||||||
|
ALTER TABLE public.thought_learning_links
|
||||||
|
ALTER COLUMN id TYPE bigint USING id::bigint;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
current_type text;
|
||||||
|
BEGIN
|
||||||
|
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
|
||||||
|
INTO current_type
|
||||||
|
FROM pg_attribute a
|
||||||
|
JOIN pg_class t ON t.oid = a.attrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND t.relname = 'thought_learning_links'
|
||||||
|
AND a.attname = 'learning_id'
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped;
|
||||||
|
|
||||||
|
IF current_type IS NOT NULL
|
||||||
|
AND current_type <> ALL(ARRAY['bigint']) THEN
|
||||||
|
ALTER TABLE public.thought_learning_links
|
||||||
|
ALTER COLUMN learning_id TYPE bigint USING learning_id::bigint;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
current_type text;
|
||||||
|
BEGIN
|
||||||
|
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
|
||||||
|
INTO current_type
|
||||||
|
FROM pg_attribute a
|
||||||
|
JOIN pg_class t ON t.oid = a.attrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND t.relname = 'thought_learning_links'
|
||||||
|
AND a.attname = 'relation'
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped;
|
||||||
|
|
||||||
|
IF current_type IS NOT NULL
|
||||||
|
AND current_type <> ALL(ARRAY['text']) THEN
|
||||||
|
ALTER TABLE public.thought_learning_links
|
||||||
|
ALTER COLUMN relation TYPE text USING relation::text;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
current_type text;
|
||||||
|
BEGIN
|
||||||
|
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
|
||||||
|
INTO current_type
|
||||||
|
FROM pg_attribute a
|
||||||
|
JOIN pg_class t ON t.oid = a.attrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND t.relname = 'thought_learning_links'
|
||||||
|
AND a.attname = 'thought_id'
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped;
|
||||||
|
|
||||||
|
IF current_type IS NOT NULL
|
||||||
|
AND current_type <> ALL(ARRAY['bigint']) THEN
|
||||||
|
ALTER TABLE public.thought_learning_links
|
||||||
|
ALTER COLUMN thought_id TYPE bigint USING thought_id::bigint;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
current_type text;
|
current_type text;
|
||||||
@@ -5890,6 +6215,29 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
current_type text;
|
||||||
|
BEGIN
|
||||||
|
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
|
||||||
|
INTO current_type
|
||||||
|
FROM pg_attribute a
|
||||||
|
JOIN pg_class t ON t.oid = a.attrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND t.relname = 'stored_files'
|
||||||
|
AND a.attname = 'tenant_key'
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped;
|
||||||
|
|
||||||
|
IF current_type IS NOT NULL
|
||||||
|
AND current_type <> ALL(ARRAY['text']) THEN
|
||||||
|
ALTER TABLE public.stored_files
|
||||||
|
ALTER COLUMN tenant_key TYPE text USING tenant_key::text;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
current_type text;
|
current_type text;
|
||||||
@@ -6166,6 +6514,29 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
current_type text;
|
||||||
|
BEGIN
|
||||||
|
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
|
||||||
|
INTO current_type
|
||||||
|
FROM pg_attribute a
|
||||||
|
JOIN pg_class t ON t.oid = a.attrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND t.relname = 'chat_histories'
|
||||||
|
AND a.attname = 'tenant_key'
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped;
|
||||||
|
|
||||||
|
IF current_type IS NOT NULL
|
||||||
|
AND current_type <> ALL(ARRAY['text']) THEN
|
||||||
|
ALTER TABLE public.chat_histories
|
||||||
|
ALTER COLUMN tenant_key TYPE text USING tenant_key::text;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
current_type text;
|
current_type text;
|
||||||
@@ -6810,6 +7181,29 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
current_type text;
|
||||||
|
BEGIN
|
||||||
|
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
|
||||||
|
INTO current_type
|
||||||
|
FROM pg_attribute a
|
||||||
|
JOIN pg_class t ON t.oid = a.attrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND t.relname = 'learnings'
|
||||||
|
AND a.attname = 'tenant_key'
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped;
|
||||||
|
|
||||||
|
IF current_type IS NOT NULL
|
||||||
|
AND current_type <> ALL(ARRAY['text']) THEN
|
||||||
|
ALTER TABLE public.learnings
|
||||||
|
ALTER COLUMN tenant_key TYPE text USING tenant_key::text;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
current_type text;
|
current_type text;
|
||||||
@@ -7270,6 +7664,29 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
current_type text;
|
||||||
|
BEGIN
|
||||||
|
SELECT pg_catalog.format_type(a.atttypid, a.atttypmod)
|
||||||
|
INTO current_type
|
||||||
|
FROM pg_attribute a
|
||||||
|
JOIN pg_class t ON t.oid = a.attrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
WHERE n.nspname = 'public'
|
||||||
|
AND t.relname = 'plans'
|
||||||
|
AND a.attname = 'tenant_key'
|
||||||
|
AND a.attnum > 0
|
||||||
|
AND NOT a.attisdropped;
|
||||||
|
|
||||||
|
IF current_type IS NOT NULL
|
||||||
|
AND current_type <> ALL(ARRAY['text']) THEN
|
||||||
|
ALTER TABLE public.plans
|
||||||
|
ALTER COLUMN tenant_key TYPE text USING tenant_key::text;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
current_type text;
|
current_type text;
|
||||||
@@ -9035,6 +9452,50 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
current_pk_name text;
|
||||||
|
current_pk_matches boolean := false;
|
||||||
|
BEGIN
|
||||||
|
SELECT tc.constraint_name,
|
||||||
|
COALESCE(
|
||||||
|
ARRAY(
|
||||||
|
SELECT a.attname::text
|
||||||
|
FROM pg_constraint c
|
||||||
|
JOIN pg_class t ON t.oid = c.conrelid
|
||||||
|
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||||
|
JOIN unnest(c.conkey) WITH ORDINALITY AS cols(attnum, ord)
|
||||||
|
ON TRUE
|
||||||
|
JOIN pg_attribute a
|
||||||
|
ON a.attrelid = t.oid
|
||||||
|
AND a.attnum = cols.attnum
|
||||||
|
WHERE c.contype = 'p'
|
||||||
|
AND n.nspname = 'public'
|
||||||
|
AND t.relname = 'thought_learning_links'
|
||||||
|
ORDER BY cols.ord
|
||||||
|
),
|
||||||
|
ARRAY[]::text[]
|
||||||
|
) = ARRAY['id']
|
||||||
|
INTO current_pk_name, current_pk_matches
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
WHERE tc.table_schema = 'public'
|
||||||
|
AND tc.table_name = 'thought_learning_links'
|
||||||
|
AND tc.constraint_type = 'PRIMARY KEY';
|
||||||
|
|
||||||
|
IF current_pk_name IS NOT NULL
|
||||||
|
AND NOT current_pk_matches
|
||||||
|
AND current_pk_name IN ('thought_learning_links_pkey', 'public_thought_learning_links_pkey') THEN
|
||||||
|
EXECUTE 'ALTER TABLE public.thought_learning_links DROP CONSTRAINT ' || quote_ident(current_pk_name) || ' CASCADE';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Add the desired primary key only when no matching primary key already exists.
|
||||||
|
IF current_pk_name IS NULL
|
||||||
|
OR (NOT current_pk_matches AND current_pk_name IN ('thought_learning_links_pkey', 'public_thought_learning_links_pkey')) THEN
|
||||||
|
ALTER TABLE public.thought_learning_links ADD CONSTRAINT pk_public_thought_learning_links PRIMARY KEY (id);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
current_pk_name text;
|
current_pk_name text;
|
||||||
@@ -9708,9 +10169,18 @@ CREATE INDEX IF NOT EXISTS idx_project_personas_project_id_persona_id
|
|||||||
CREATE INDEX IF NOT EXISTS idx_arc_stage_parts_stage_id_part_id
|
CREATE INDEX IF NOT EXISTS idx_arc_stage_parts_stage_id_part_id
|
||||||
ON public.arc_stage_parts USING btree (stage_id, part_id);
|
ON public.arc_stage_parts USING btree (stage_id, part_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_thoughts_tenant_key_project_id
|
||||||
|
ON public.thoughts USING btree (tenant_key, project_id);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uidx_projects_tenant_key_name
|
||||||
|
ON public.projects USING btree (tenant_key, name);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_thought_links_from_id_to_id_relation
|
CREATE INDEX IF NOT EXISTS idx_thought_links_from_id_to_id_relation
|
||||||
ON public.thought_links USING btree (from_id, to_id, relation);
|
ON public.thought_links USING btree (from_id, to_id, relation);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uidx_thought_learning_links_thought_id_learning_id
|
||||||
|
ON public.thought_learning_links USING btree (thought_id, learning_id);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uidx_embeddings_thought_id_model
|
CREATE UNIQUE INDEX IF NOT EXISTS uidx_embeddings_thought_id_model
|
||||||
ON public.embeddings USING btree (thought_id, model);
|
ON public.embeddings USING btree (thought_id, model);
|
||||||
|
|
||||||
@@ -9865,19 +10335,6 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.table_constraints
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'projects'
|
|
||||||
AND constraint_name = 'ukey_projects_name'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE public.projects ADD CONSTRAINT ukey_projects_name UNIQUE (name);
|
|
||||||
END IF;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
@@ -10328,6 +10785,38 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$;DO $$
|
$$;DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'thought_learning_links'
|
||||||
|
AND constraint_name = 'fk_thought_learning_links_learning_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.thought_learning_links
|
||||||
|
ADD CONSTRAINT fk_thought_learning_links_learning_id
|
||||||
|
FOREIGN KEY (learning_id)
|
||||||
|
REFERENCES public.learnings (id)
|
||||||
|
ON DELETE NO ACTION
|
||||||
|
ON UPDATE NO ACTION;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'thought_learning_links'
|
||||||
|
AND constraint_name = 'fk_thought_learning_links_thought_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.thought_learning_links
|
||||||
|
ADD CONSTRAINT fk_thought_learning_links_thought_id
|
||||||
|
FOREIGN KEY (thought_id)
|
||||||
|
REFERENCES public.thoughts (id)
|
||||||
|
ON DELETE NO ACTION
|
||||||
|
ON UPDATE NO ACTION;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
SELECT 1 FROM information_schema.table_constraints
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
@@ -10982,6 +11471,25 @@ BEGIN
|
|||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
DO $$
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
m_cnt bigint;
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_class c
|
||||||
|
INNER JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.relname = 'identity_thought_learning_links_id'
|
||||||
|
AND n.nspname = 'public'
|
||||||
|
AND c.relkind = 'S'
|
||||||
|
) THEN
|
||||||
|
SELECT COALESCE(MAX(id), 0) + 1
|
||||||
|
FROM public.thought_learning_links
|
||||||
|
INTO m_cnt;
|
||||||
|
|
||||||
|
PERFORM setval('public.identity_thought_learning_links_id'::regclass, m_cnt);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
m_cnt bigint;
|
m_cnt bigint;
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -11295,5 +11803,6 @@ $$;
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
+13
-1
@@ -6,16 +6,28 @@ Table thoughts {
|
|||||||
created_at timestamptz [default: `now()`]
|
created_at timestamptz [default: `now()`]
|
||||||
updated_at timestamptz [default: `now()`]
|
updated_at timestamptz [default: `now()`]
|
||||||
project_id bigint [ref: > projects.id]
|
project_id bigint [ref: > projects.id]
|
||||||
|
tenant_key text
|
||||||
archived_at timestamptz
|
archived_at timestamptz
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
tenant_key
|
||||||
|
(tenant_key, project_id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Table projects {
|
Table projects {
|
||||||
id bigserial [pk]
|
id bigserial [pk]
|
||||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||||
name text [unique, not null]
|
name text [not null]
|
||||||
description text
|
description text
|
||||||
|
tenant_key text
|
||||||
created_at timestamptz [default: `now()`]
|
created_at timestamptz [default: `now()`]
|
||||||
last_active_at timestamptz [default: `now()`]
|
last_active_at timestamptz [default: `now()`]
|
||||||
|
|
||||||
|
indexes {
|
||||||
|
(tenant_key, name) [unique]
|
||||||
|
tenant_key
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Table thought_links {
|
Table thought_links {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Table stored_files {
|
|||||||
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
guid uuid [unique, not null, default: `gen_random_uuid()`]
|
||||||
thought_id bigint [ref: > thoughts.id]
|
thought_id bigint [ref: > thoughts.id]
|
||||||
project_id bigint [ref: > projects.id]
|
project_id bigint [ref: > projects.id]
|
||||||
|
tenant_key text
|
||||||
name text [not null]
|
name text [not null]
|
||||||
media_type text [not null]
|
media_type text [not null]
|
||||||
kind text [not null, default: 'file']
|
kind text [not null, default: 'file']
|
||||||
@@ -16,6 +17,7 @@ Table stored_files {
|
|||||||
indexes {
|
indexes {
|
||||||
thought_id
|
thought_id
|
||||||
project_id
|
project_id
|
||||||
|
tenant_key
|
||||||
sha256
|
sha256
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Table chat_histories {
|
|||||||
channel text
|
channel text
|
||||||
agent_id text
|
agent_id text
|
||||||
project_id bigint [ref: > projects.id]
|
project_id bigint [ref: > projects.id]
|
||||||
|
tenant_key text
|
||||||
messages jsonb [not null, default: `'[]'`]
|
messages jsonb [not null, default: `'[]'`]
|
||||||
summary text
|
summary text
|
||||||
metadata jsonb [not null, default: `'{}'`]
|
metadata jsonb [not null, default: `'{}'`]
|
||||||
@@ -15,6 +16,7 @@ Table chat_histories {
|
|||||||
indexes {
|
indexes {
|
||||||
session_id
|
session_id
|
||||||
project_id
|
project_id
|
||||||
|
tenant_key
|
||||||
channel
|
channel
|
||||||
agent_id
|
agent_id
|
||||||
created_at
|
created_at
|
||||||
@@ -46,6 +48,7 @@ Table learnings {
|
|||||||
source_type text
|
source_type text
|
||||||
source_ref text
|
source_ref text
|
||||||
project_id bigint [ref: > projects.id]
|
project_id bigint [ref: > projects.id]
|
||||||
|
tenant_key text
|
||||||
related_thought_id bigint [ref: > thoughts.id]
|
related_thought_id bigint [ref: > thoughts.id]
|
||||||
related_skill_id bigint [ref: > agent_skills.id]
|
related_skill_id bigint [ref: > agent_skills.id]
|
||||||
reviewed_by text
|
reviewed_by text
|
||||||
@@ -58,6 +61,7 @@ Table learnings {
|
|||||||
|
|
||||||
indexes {
|
indexes {
|
||||||
project_id
|
project_id
|
||||||
|
tenant_key
|
||||||
category
|
category
|
||||||
area
|
area
|
||||||
status
|
status
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Table plans {
|
|||||||
status text [not null, default: 'draft'] // draft, active, blocked, completed, cancelled, superseded
|
status text [not null, default: 'draft'] // draft, active, blocked, completed, cancelled, superseded
|
||||||
priority text [not null, default: 'medium'] // low, medium, high, critical
|
priority text [not null, default: 'medium'] // low, medium, high, critical
|
||||||
project_id bigint [ref: > projects.id]
|
project_id bigint [ref: > projects.id]
|
||||||
|
tenant_key text
|
||||||
owner text
|
owner text
|
||||||
due_date timestamptz
|
due_date timestamptz
|
||||||
completed_at timestamptz
|
completed_at timestamptz
|
||||||
@@ -18,6 +19,7 @@ Table plans {
|
|||||||
|
|
||||||
indexes {
|
indexes {
|
||||||
project_id
|
project_id
|
||||||
|
tenant_key
|
||||||
status
|
status
|
||||||
priority
|
priority
|
||||||
owner
|
owner
|
||||||
|
|||||||
+73
@@ -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.
|
||||||
+305
-1
@@ -1,15 +1,85 @@
|
|||||||
package spectypes
|
package sqltypes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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}`)
|
// 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.
|
// into a slice of raw string elements. Each element retains its unquoted/unescaped value.
|
||||||
func parsePostgresArrayElements(s string) ([]string, error) {
|
func parsePostgresArrayElements(s string) ([]string, error) {
|
||||||
@@ -140,6 +210,32 @@ func (a *SqlStringArray) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
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 {
|
func NewSqlStringArray(v []string) SqlStringArray {
|
||||||
return SqlStringArray{Val: v, Valid: true}
|
return SqlStringArray{Val: v, Valid: true}
|
||||||
}
|
}
|
||||||
@@ -215,6 +311,32 @@ func (a *SqlInt16Array) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
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 {
|
func NewSqlInt16Array(v []int16) SqlInt16Array {
|
||||||
return SqlInt16Array{Val: v, Valid: true}
|
return SqlInt16Array{Val: v, Valid: true}
|
||||||
}
|
}
|
||||||
@@ -290,6 +412,32 @@ func (a *SqlInt32Array) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
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 {
|
func NewSqlInt32Array(v []int32) SqlInt32Array {
|
||||||
return SqlInt32Array{Val: v, Valid: true}
|
return SqlInt32Array{Val: v, Valid: true}
|
||||||
}
|
}
|
||||||
@@ -365,6 +513,32 @@ func (a *SqlInt64Array) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
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 {
|
func NewSqlInt64Array(v []int64) SqlInt64Array {
|
||||||
return SqlInt64Array{Val: v, Valid: true}
|
return SqlInt64Array{Val: v, Valid: true}
|
||||||
}
|
}
|
||||||
@@ -440,6 +614,32 @@ func (a *SqlFloat32Array) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
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 {
|
func NewSqlFloat32Array(v []float32) SqlFloat32Array {
|
||||||
return SqlFloat32Array{Val: v, Valid: true}
|
return SqlFloat32Array{Val: v, Valid: true}
|
||||||
}
|
}
|
||||||
@@ -515,6 +715,32 @@ func (a *SqlFloat64Array) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
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 {
|
func NewSqlFloat64Array(v []float64) SqlFloat64Array {
|
||||||
return SqlFloat64Array{Val: v, Valid: true}
|
return SqlFloat64Array{Val: v, Valid: true}
|
||||||
}
|
}
|
||||||
@@ -591,6 +817,32 @@ func (a *SqlBoolArray) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
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 {
|
func NewSqlBoolArray(v []bool) SqlBoolArray {
|
||||||
return SqlBoolArray{Val: v, Valid: true}
|
return SqlBoolArray{Val: v, Valid: true}
|
||||||
}
|
}
|
||||||
@@ -666,6 +918,32 @@ func (a *SqlUUIDArray) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
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 {
|
func NewSqlUUIDArray(v []uuid.UUID) SqlUUIDArray {
|
||||||
return SqlUUIDArray{Val: v, Valid: true}
|
return SqlUUIDArray{Val: v, Valid: true}
|
||||||
}
|
}
|
||||||
@@ -750,6 +1028,32 @@ func (v *SqlVector) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
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 {
|
func NewSqlVector(val []float32) SqlVector {
|
||||||
return SqlVector{Val: val, Valid: true}
|
return SqlVector{Val: val, Valid: true}
|
||||||
}
|
}
|
||||||
+331
-8
@@ -1,11 +1,12 @@
|
|||||||
// Package spectypes provides nullable SQL types with automatic casting and conversion methods.
|
// Package sqltypes provides nullable SQL types with automatic casting and conversion methods.
|
||||||
package spectypes
|
package sqltypes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tryParseDT attempts to parse a string into a time.Time using various formats.
|
// 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
|
var zero T
|
||||||
switch any(zero).(type) {
|
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 {
|
if i, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||||
reflect.ValueOf(&n.Val).Elem().SetInt(i)
|
reflect.ValueOf(&n.Val).Elem().SetInt(i)
|
||||||
n.Valid = true
|
n.Valid = true
|
||||||
}
|
} else if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
|
||||||
reflect.ValueOf(&n.Val).Elem().SetInt(int64(f))
|
reflect.ValueOf(&n.Val).Elem().SetInt(int64(f))
|
||||||
n.Valid = true
|
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:
|
case float32, float64:
|
||||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||||
reflect.ValueOf(&n.Val).Elem().SetFloat(f)
|
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)
|
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.
|
// String implements fmt.Stringer.
|
||||||
func (n SqlNull[T]) String() string {
|
func (n SqlNull[T]) String() string {
|
||||||
if !n.Valid {
|
if !n.Valid {
|
||||||
@@ -329,6 +436,7 @@ type (
|
|||||||
SqlInt16 = SqlNull[int16]
|
SqlInt16 = SqlNull[int16]
|
||||||
SqlInt32 = SqlNull[int32]
|
SqlInt32 = SqlNull[int32]
|
||||||
SqlInt64 = SqlNull[int64]
|
SqlInt64 = SqlNull[int64]
|
||||||
|
SqlFloat32 = SqlNull[float32]
|
||||||
SqlFloat64 = SqlNull[float64]
|
SqlFloat64 = SqlNull[float64]
|
||||||
SqlBool = SqlNull[bool]
|
SqlBool = SqlNull[bool]
|
||||||
SqlString = SqlNull[string]
|
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)) {
|
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("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 {
|
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
|
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 {
|
func SqlTimeStampNow() SqlTimeStamp {
|
||||||
return SqlTimeStamp{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
|
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") {
|
if strings.HasPrefix(s, "0001-01-01") {
|
||||||
return []byte("null"), nil
|
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 {
|
func (d *SqlDate) UnmarshalJSON(b []byte) error {
|
||||||
@@ -413,6 +564,57 @@ func (d SqlDate) String() string {
|
|||||||
return s
|
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 {
|
func SqlDateNow() SqlDate {
|
||||||
return SqlDate{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
|
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" {
|
if s == "00:00:00" {
|
||||||
return []byte("null"), nil
|
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 {
|
func (t *SqlTime) UnmarshalJSON(b []byte) error {
|
||||||
@@ -455,6 +657,57 @@ func (t SqlTime) String() string {
|
|||||||
return t.Val.Format("15:04:05")
|
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 {
|
func SqlTimeNow() SqlTime {
|
||||||
return SqlTime{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
|
return SqlTime{SqlNull: SqlNull[time.Time]{Val: time.Now(), Valid: true}}
|
||||||
}
|
}
|
||||||
@@ -462,6 +715,11 @@ func SqlTimeNow() SqlTime {
|
|||||||
// SqlJSONB - Nullable JSONB as []byte.
|
// SqlJSONB - Nullable JSONB as []byte.
|
||||||
type SqlJSONB []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.
|
// Scan implements sql.Scanner.
|
||||||
func (n *SqlJSONB) Scan(value any) error {
|
func (n *SqlJSONB) Scan(value any) error {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
@@ -518,6 +776,67 @@ func (n *SqlJSONB) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
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) {
|
func (n SqlJSONB) AsMap() (map[string]any, error) {
|
||||||
if len(n) == 0 {
|
if len(n) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -625,6 +944,10 @@ func NewSqlInt64(v int64) SqlInt64 {
|
|||||||
return SqlInt64{Val: v, Valid: true}
|
return SqlInt64{Val: v, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewSqlFloat32(v float32) SqlFloat32 {
|
||||||
|
return SqlFloat32{Val: v, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
func NewSqlFloat64(v float64) SqlFloat64 {
|
func NewSqlFloat64(v float64) SqlFloat64 {
|
||||||
return SqlFloat64{Val: v, Valid: true}
|
return SqlFloat64{Val: v, Valid: true}
|
||||||
}
|
}
|
||||||
+11
@@ -78,6 +78,17 @@ func (s *securityContext) GetUserID() (int, bool) {
|
|||||||
return security.GetUserID(s.ctx.Context)
|
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 {
|
func (s *securityContext) GetSchema() string {
|
||||||
return s.ctx.Schema
|
return s.ctx.Schema
|
||||||
}
|
}
|
||||||
|
|||||||
+90
-1
@@ -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
|
- ✅ **No Global State** - Each handler has its own security configuration
|
||||||
- ✅ **Testable** - Easy to mock and test
|
- ✅ **Testable** - Easy to mock and test
|
||||||
- ✅ **Extensible** - Implement custom providers for your needs
|
- ✅ **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
|
- ✅ **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
|
- ✅ **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.
|
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
|
## Quick Start
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|||||||
+2
-2
@@ -74,8 +74,8 @@ func (c *CompositeSecurityProvider) GetColumnSecurity(ctx context.Context, userI
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetRowSecurity delegates to the row security provider
|
// GetRowSecurity delegates to the row security provider
|
||||||
func (c *CompositeSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
|
func (c *CompositeSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
|
||||||
return c.rowSec.GetRowSecurity(ctx, userID, schema, table)
|
return c.rowSec.GetRowSecurity(ctx, userRef, schema, table)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional interface implementations (if wrapped providers support them)
|
// Optional interface implementations (if wrapped providers support them)
|
||||||
|
|||||||
+141
@@ -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
@@ -14,6 +14,11 @@ import (
|
|||||||
type SecurityContext interface {
|
type SecurityContext interface {
|
||||||
GetContext() context.Context
|
GetContext() context.Context
|
||||||
GetUserID() (int, bool)
|
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
|
GetSchema() string
|
||||||
GetEntity() string
|
GetEntity() string
|
||||||
GetModel() interface{}
|
GetModel() interface{}
|
||||||
@@ -45,8 +50,13 @@ func loadSecurityRules(secCtx SecurityContext, securityList *SecurityList) error
|
|||||||
// return err
|
// return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load row security rules using the provider
|
// Load row security rules using the provider. Row security uses the opaque
|
||||||
_, err = securityList.LoadRowSecurity(secCtx.GetContext(), userID, schema, tablename, false)
|
// 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 {
|
if err != nil {
|
||||||
logger.Warn("Failed to load row security: %v", err)
|
logger.Warn("Failed to load row security: %v", err)
|
||||||
// Don't fail the request if no security rules exist
|
// 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)
|
// applyRowSecurity applies row-level security filters to the query (generic version)
|
||||||
func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error {
|
func applyRowSecurity(secCtx SecurityContext, securityList *SecurityList) error {
|
||||||
userID, ok := secCtx.GetUserID()
|
userRef, ok := secCtx.GetUserRef()
|
||||||
if !ok {
|
if !ok {
|
||||||
|
userID, idOK := secCtx.GetUserID()
|
||||||
|
if !idOK {
|
||||||
return nil // No user context, skip
|
return nil // No user context, skip
|
||||||
}
|
}
|
||||||
|
userRef = userID
|
||||||
|
}
|
||||||
|
|
||||||
schema := secCtx.GetSchema()
|
schema := secCtx.GetSchema()
|
||||||
tablename := secCtx.GetEntity()
|
tablename := secCtx.GetEntity()
|
||||||
|
|
||||||
// Get row security template
|
// Get row security template
|
||||||
rowSec, err := securityList.GetRowSecurityTemplate(userID, schema, tablename)
|
rowSec, err := securityList.GetRowSecurityTemplate(userRef, schema, tablename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// No row security defined, allow query to proceed
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user has a blocking rule
|
// Check if user has a blocking rule
|
||||||
if rowSec.HasBlock {
|
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)
|
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
|
// Generate the WHERE clause from template
|
||||||
whereClause := rowSec.GetTemplate(pkName, modelType)
|
whereClause := rowSec.GetTemplate(pkName, modelType)
|
||||||
|
|
||||||
logger.Info("Applying row security filter for user %d on %s.%s: %s",
|
logger.Info("Applying row security filter for user %v on %s.%s: %s",
|
||||||
userID, schema, tablename, whereClause)
|
userRef, schema, tablename, whereClause)
|
||||||
|
|
||||||
// Apply the WHERE clause to the query
|
// Apply the WHERE clause to the query
|
||||||
query := secCtx.GetQuery()
|
query := secCtx.GetQuery()
|
||||||
|
|||||||
+6
-2
@@ -121,8 +121,12 @@ type ColumnSecurityProvider interface {
|
|||||||
|
|
||||||
// RowSecurityProvider handles row-level security (filtering)
|
// RowSecurityProvider handles row-level security (filtering)
|
||||||
type RowSecurityProvider interface {
|
type RowSecurityProvider interface {
|
||||||
// GetRowSecurity loads row security rules for a user and entity
|
// GetRowSecurity loads row security rules for a user and entity.
|
||||||
GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error)
|
// 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
|
// SecurityProvider is the main interface combining all security concerns
|
||||||
|
|||||||
+41
@@ -23,6 +23,10 @@ type DatabaseKeyStoreOptions struct {
|
|||||||
CacheTTL time.Duration
|
CacheTTL time.Duration
|
||||||
// SQLNames provides custom procedure names. If nil, uses DefaultKeyStoreSQLNames().
|
// SQLNames provides custom procedure names. If nil, uses DefaultKeyStoreSQLNames().
|
||||||
SQLNames *KeyStoreSQLNames
|
SQLNames *KeyStoreSQLNames
|
||||||
|
// TableNames provides custom table names for Direct mode. If nil, uses DefaultKeyStoreTableNames().
|
||||||
|
TableNames *KeyStoreTableNames
|
||||||
|
// QueryMode selects stored-procedure vs Direct-mode SQL. Default (zero value) is ModeAuto.
|
||||||
|
QueryMode QueryMode
|
||||||
// DBFactory is called to obtain a fresh *sql.DB when the existing connection is closed.
|
// DBFactory is called to obtain a fresh *sql.DB when the existing connection is closed.
|
||||||
// If nil, reconnection is disabled.
|
// If nil, reconnection is disabled.
|
||||||
DBFactory func() (*sql.DB, error)
|
DBFactory func() (*sql.DB, error)
|
||||||
@@ -42,6 +46,9 @@ type DatabaseKeyStore struct {
|
|||||||
dbMu sync.RWMutex
|
dbMu sync.RWMutex
|
||||||
dbFactory func() (*sql.DB, error)
|
dbFactory func() (*sql.DB, error)
|
||||||
sqlNames *KeyStoreSQLNames
|
sqlNames *KeyStoreSQLNames
|
||||||
|
tableNames *KeyStoreTableNames
|
||||||
|
queryMode QueryMode
|
||||||
|
capability *dbCapability
|
||||||
cache *cache.Cache
|
cache *cache.Cache
|
||||||
cacheTTL time.Duration
|
cacheTTL time.Duration
|
||||||
}
|
}
|
||||||
@@ -60,10 +67,14 @@ func NewDatabaseKeyStore(db *sql.DB, opts ...DatabaseKeyStoreOptions) *DatabaseK
|
|||||||
c = cache.GetDefaultCache()
|
c = cache.GetDefaultCache()
|
||||||
}
|
}
|
||||||
names := MergeKeyStoreSQLNames(DefaultKeyStoreSQLNames(), o.SQLNames)
|
names := MergeKeyStoreSQLNames(DefaultKeyStoreSQLNames(), o.SQLNames)
|
||||||
|
tableNames := resolveKeyStoreTableNames(o.TableNames)
|
||||||
return &DatabaseKeyStore{
|
return &DatabaseKeyStore{
|
||||||
db: db,
|
db: db,
|
||||||
dbFactory: o.DBFactory,
|
dbFactory: o.DBFactory,
|
||||||
sqlNames: names,
|
sqlNames: names,
|
||||||
|
tableNames: tableNames,
|
||||||
|
queryMode: o.QueryMode,
|
||||||
|
capability: newDBCapability(),
|
||||||
cache: c,
|
cache: c,
|
||||||
cacheTTL: o.CacheTTL,
|
cacheTTL: o.CacheTTL,
|
||||||
}
|
}
|
||||||
@@ -86,6 +97,9 @@ func (ks *DatabaseKeyStore) reconnectDB() error {
|
|||||||
ks.dbMu.Lock()
|
ks.dbMu.Lock()
|
||||||
ks.db = newDB
|
ks.db = newDB
|
||||||
ks.dbMu.Unlock()
|
ks.dbMu.Unlock()
|
||||||
|
if ks.capability != nil {
|
||||||
|
ks.capability.reset()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +113,14 @@ func (ks *DatabaseKeyStore) CreateKey(ctx context.Context, req CreateKeyRequest)
|
|||||||
rawKey := base64.RawURLEncoding.EncodeToString(rawBytes)
|
rawKey := base64.RawURLEncoding.EncodeToString(rawBytes)
|
||||||
hash := hashSHA256Hex(rawKey)
|
hash := hashSHA256Hex(rawKey)
|
||||||
|
|
||||||
|
if !ks.capability.ShouldUseProcedure(ctx, ks.queryMode, ks.getDB(), ks.sqlNames.CreateKey) {
|
||||||
|
key, err := ks.createKeyDirect(ctx, req, hash)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &CreateKeyResponse{Key: *key, RawKey: rawKey}, nil
|
||||||
|
}
|
||||||
|
|
||||||
type createRequest struct {
|
type createRequest struct {
|
||||||
UserID int `json:"user_id"`
|
UserID int `json:"user_id"`
|
||||||
KeyType KeyType `json:"key_type"`
|
KeyType KeyType `json:"key_type"`
|
||||||
@@ -145,6 +167,10 @@ func (ks *DatabaseKeyStore) CreateKey(ctx context.Context, req CreateKeyRequest)
|
|||||||
// GetUserKeys returns all active, non-expired keys for the given user.
|
// GetUserKeys returns all active, non-expired keys for the given user.
|
||||||
// Pass an empty KeyType to return all types.
|
// Pass an empty KeyType to return all types.
|
||||||
func (ks *DatabaseKeyStore) GetUserKeys(ctx context.Context, userID int, keyType KeyType) ([]UserKey, error) {
|
func (ks *DatabaseKeyStore) GetUserKeys(ctx context.Context, userID int, keyType KeyType) ([]UserKey, error) {
|
||||||
|
if !ks.capability.ShouldUseProcedure(ctx, ks.queryMode, ks.getDB(), ks.sqlNames.GetUserKeys) {
|
||||||
|
return ks.getUserKeysDirect(ctx, userID, keyType)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
var keysJSON sql.NullString
|
var keysJSON sql.NullString
|
||||||
@@ -173,6 +199,10 @@ func (ks *DatabaseKeyStore) GetUserKeys(ctx context.Context, userID int, keyType
|
|||||||
// The delete procedure returns the key_hash so no separate lookup is needed.
|
// The delete procedure returns the key_hash so no separate lookup is needed.
|
||||||
// Note: cache invalidation is best-effort; a cached entry may persist for up to CacheTTL.
|
// Note: cache invalidation is best-effort; a cached entry may persist for up to CacheTTL.
|
||||||
func (ks *DatabaseKeyStore) DeleteKey(ctx context.Context, userID int, keyID int64) error {
|
func (ks *DatabaseKeyStore) DeleteKey(ctx context.Context, userID int, keyID int64) error {
|
||||||
|
if !ks.capability.ShouldUseProcedure(ctx, ks.queryMode, ks.getDB(), ks.sqlNames.DeleteKey) {
|
||||||
|
return ks.deleteKeyDirect(ctx, userID, keyID)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
var keyHash sql.NullString
|
var keyHash sql.NullString
|
||||||
@@ -207,6 +237,17 @@ func (ks *DatabaseKeyStore) ValidateKey(ctx context.Context, rawKey string, keyT
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !ks.capability.ShouldUseProcedure(ctx, ks.queryMode, ks.getDB(), ks.sqlNames.ValidateKey) {
|
||||||
|
key, err := ks.validateKeyDirect(ctx, hash, keyType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ks.cache != nil {
|
||||||
|
_ = ks.cache.Set(ctx, cacheKey, *key, ks.cacheTTL)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
var keyJSON sql.NullString
|
var keyJSON sql.NullString
|
||||||
|
|||||||
+216
@@ -0,0 +1,216 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Direct-mode implementations mirroring the resolvespec_keystore_* stored
|
||||||
|
// procedures in keystore_schema.sql using plain SQL against
|
||||||
|
// TableNames.UserKeys. meta/scopes are stored as JSON-encoded TEXT instead
|
||||||
|
// of Postgres JSONB.
|
||||||
|
|
||||||
|
func (ks *DatabaseKeyStore) createKeyDirect(ctx context.Context, req CreateKeyRequest, keyHash string) (*UserKey, error) {
|
||||||
|
scopesJSON, err := json.Marshal(req.Scopes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal scopes: %w", err)
|
||||||
|
}
|
||||||
|
var metaJSON []byte
|
||||||
|
if req.Meta != nil {
|
||||||
|
metaJSON, err = json.Marshal(req.Meta)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal meta: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
var id int64
|
||||||
|
err = ks.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`INSERT INTO %s (user_id, key_type, key_hash, name, scopes, meta, expires_at, created_at, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
ks.tableNames.UserKeys))
|
||||||
|
res, err := db.ExecContext(ctx, query, req.UserID, string(req.KeyType), keyHash, req.Name, string(scopesJSON), nullableString(metaJSON), req.ExpiresAt, now, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, err = res.LastInsertId()
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create key query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UserKey{
|
||||||
|
ID: id,
|
||||||
|
UserID: req.UserID,
|
||||||
|
KeyType: req.KeyType,
|
||||||
|
KeyHash: keyHash,
|
||||||
|
Name: req.Name,
|
||||||
|
Scopes: req.Scopes,
|
||||||
|
Meta: req.Meta,
|
||||||
|
ExpiresAt: req.ExpiresAt,
|
||||||
|
CreatedAt: now,
|
||||||
|
IsActive: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ks *DatabaseKeyStore) runDBOpWithReconnect(run func(*sql.DB) error) error {
|
||||||
|
db := ks.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return fmt.Errorf("database connection is nil")
|
||||||
|
}
|
||||||
|
err := run(db)
|
||||||
|
if isDBClosed(err) {
|
||||||
|
if reconnErr := ks.reconnectDB(); reconnErr == nil {
|
||||||
|
err = run(ks.getDB())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ks *DatabaseKeyStore) getUserKeysDirect(ctx context.Context, userID int, keyType KeyType) ([]UserKey, error) {
|
||||||
|
keys := []UserKey{}
|
||||||
|
err := ks.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
var query string
|
||||||
|
var args []any
|
||||||
|
if keyType == "" {
|
||||||
|
query = rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`SELECT id, user_id, key_type, name, scopes, meta, expires_at, created_at, last_used_at, is_active
|
||||||
|
FROM %s WHERE user_id = ? AND is_active = ? AND (expires_at IS NULL OR expires_at > ?)`, ks.tableNames.UserKeys))
|
||||||
|
args = []any{userID, true, time.Now()}
|
||||||
|
} else {
|
||||||
|
query = rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`SELECT id, user_id, key_type, name, scopes, meta, expires_at, created_at, last_used_at, is_active
|
||||||
|
FROM %s WHERE user_id = ? AND is_active = ? AND (expires_at IS NULL OR expires_at > ?) AND key_type = ?`, ks.tableNames.UserKeys))
|
||||||
|
args = []any{userID, true, time.Now(), string(keyType)}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var k UserKey
|
||||||
|
var kt string
|
||||||
|
var scopesJSON, metaJSON sql.NullString
|
||||||
|
var expiresAt, lastUsedAt sql.NullTime
|
||||||
|
if err := rows.Scan(&k.ID, &k.UserID, &kt, &k.Name, &scopesJSON, &metaJSON, &expiresAt, &k.CreatedAt, &lastUsedAt, &k.IsActive); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
k.KeyType = KeyType(kt)
|
||||||
|
if scopesJSON.Valid && scopesJSON.String != "" {
|
||||||
|
_ = json.Unmarshal([]byte(scopesJSON.String), &k.Scopes)
|
||||||
|
}
|
||||||
|
if metaJSON.Valid && metaJSON.String != "" {
|
||||||
|
_ = json.Unmarshal([]byte(metaJSON.String), &k.Meta)
|
||||||
|
}
|
||||||
|
if expiresAt.Valid {
|
||||||
|
t := expiresAt.Time
|
||||||
|
k.ExpiresAt = &t
|
||||||
|
}
|
||||||
|
if lastUsedAt.Valid {
|
||||||
|
t := lastUsedAt.Time
|
||||||
|
k.LastUsedAt = &t
|
||||||
|
}
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get user keys query failed: %w", err)
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ks *DatabaseKeyStore) deleteKeyDirect(ctx context.Context, userID int, keyID int64) error {
|
||||||
|
var keyHash string
|
||||||
|
err := ks.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
selQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT key_hash FROM %s WHERE id = ? AND user_id = ? AND is_active = ?`, ks.tableNames.UserKeys))
|
||||||
|
if err := db.QueryRowContext(ctx, selQuery, keyID, userID, true).Scan(&keyHash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET is_active = ? WHERE id = ? AND user_id = ? AND is_active = ?`, ks.tableNames.UserKeys))
|
||||||
|
_, err := db.ExecContext(ctx, updQuery, false, keyID, userID, true)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return errors.New("key not found or already deleted")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("delete key query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if keyHash != "" && ks.cache != nil {
|
||||||
|
_ = ks.cache.Delete(ctx, keystoreCacheKey(keyHash))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ks *DatabaseKeyStore) validateKeyDirect(ctx context.Context, keyHash string, keyType KeyType) (*UserKey, error) {
|
||||||
|
var k UserKey
|
||||||
|
var kt string
|
||||||
|
var scopesJSON, metaJSON sql.NullString
|
||||||
|
var expiresAt, lastUsedAt sql.NullTime
|
||||||
|
|
||||||
|
err := ks.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
var query string
|
||||||
|
var args []any
|
||||||
|
if keyType == "" {
|
||||||
|
query = rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`SELECT id, user_id, key_type, name, scopes, meta, expires_at, created_at, is_active
|
||||||
|
FROM %s WHERE key_hash = ? AND is_active = ? AND (expires_at IS NULL OR expires_at > ?)`, ks.tableNames.UserKeys))
|
||||||
|
args = []any{keyHash, true, time.Now()}
|
||||||
|
} else {
|
||||||
|
query = rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`SELECT id, user_id, key_type, name, scopes, meta, expires_at, created_at, is_active
|
||||||
|
FROM %s WHERE key_hash = ? AND is_active = ? AND (expires_at IS NULL OR expires_at > ?) AND key_type = ?`, ks.tableNames.UserKeys))
|
||||||
|
args = []any{keyHash, true, time.Now(), string(keyType)}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.QueryRowContext(ctx, query, args...).Scan(&k.ID, &k.UserID, &kt, &k.Name, &scopesJSON, &metaJSON, &expiresAt, &k.CreatedAt, &k.IsActive); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET last_used_at = ? WHERE id = ?`, ks.tableNames.UserKeys))
|
||||||
|
_, err := db.ExecContext(ctx, updQuery, now, k.ID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, errors.New("invalid or expired key")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("validate key query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
k.KeyType = KeyType(kt)
|
||||||
|
k.KeyHash = keyHash
|
||||||
|
if scopesJSON.Valid && scopesJSON.String != "" {
|
||||||
|
_ = json.Unmarshal([]byte(scopesJSON.String), &k.Scopes)
|
||||||
|
}
|
||||||
|
if metaJSON.Valid && metaJSON.String != "" {
|
||||||
|
_ = json.Unmarshal([]byte(metaJSON.String), &k.Meta)
|
||||||
|
}
|
||||||
|
if expiresAt.Valid {
|
||||||
|
t := expiresAt.Time
|
||||||
|
k.ExpiresAt = &t
|
||||||
|
}
|
||||||
|
_ = lastUsedAt
|
||||||
|
now := time.Now()
|
||||||
|
k.LastUsedAt = &now
|
||||||
|
|
||||||
|
return &k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableString(b []byte) any {
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// KeyStoreTableNames holds the configurable table name used by DatabaseKeyStore
|
||||||
|
// in Direct mode. Use DefaultKeyStoreTableNames() for defaults and
|
||||||
|
// MergeKeyStoreTableNames() for partial overrides.
|
||||||
|
type KeyStoreTableNames struct {
|
||||||
|
UserKeys string // default: "user_keys"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultKeyStoreTableNames returns a KeyStoreTableNames with default table names.
|
||||||
|
func DefaultKeyStoreTableNames() *KeyStoreTableNames {
|
||||||
|
return &KeyStoreTableNames{
|
||||||
|
UserKeys: "user_keys",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeKeyStoreTableNames returns a copy of base with any non-empty fields from override applied.
|
||||||
|
// If override is nil, a copy of base is returned.
|
||||||
|
func MergeKeyStoreTableNames(base, override *KeyStoreTableNames) *KeyStoreTableNames {
|
||||||
|
if override == nil {
|
||||||
|
copied := *base
|
||||||
|
return &copied
|
||||||
|
}
|
||||||
|
merged := *base
|
||||||
|
if override.UserKeys != "" {
|
||||||
|
merged.UserKeys = override.UserKeys
|
||||||
|
}
|
||||||
|
return &merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateKeyStoreTableNames checks that all non-empty table names are valid SQL identifiers.
|
||||||
|
func ValidateKeyStoreTableNames(names *KeyStoreTableNames) error {
|
||||||
|
if names.UserKeys != "" && !validSQLIdentifier.MatchString(names.UserKeys) {
|
||||||
|
return fmt.Errorf("KeyStoreTableNames.UserKeys contains invalid characters: %q", names.UserKeys)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveKeyStoreTableNames merges an optional override with defaults.
|
||||||
|
func resolveKeyStoreTableNames(override *KeyStoreTableNames) *KeyStoreTableNames {
|
||||||
|
return MergeKeyStoreTableNames(DefaultKeyStoreTableNames(), override)
|
||||||
|
}
|
||||||
+15
-83
@@ -226,6 +226,10 @@ func (a *DatabaseAuthenticator) getOAuth2Provider(providerName string) (*OAuth2P
|
|||||||
|
|
||||||
// oauth2GetOrCreateUser finds or creates a user based on OAuth2 info using stored procedure
|
// oauth2GetOrCreateUser finds or creates a user based on OAuth2 info using stored procedure
|
||||||
func (a *DatabaseAuthenticator) oauth2GetOrCreateUser(ctx context.Context, userCtx *UserContext, providerName string) (int, error) {
|
func (a *DatabaseAuthenticator) oauth2GetOrCreateUser(ctx context.Context, userCtx *UserContext, providerName string) (int, error) {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthGetOrCreateUser) {
|
||||||
|
return a.oauth2GetOrCreateUserDirect(ctx, userCtx, providerName)
|
||||||
|
}
|
||||||
|
|
||||||
userData := map[string]interface{}{
|
userData := map[string]interface{}{
|
||||||
"username": userCtx.UserName,
|
"username": userCtx.UserName,
|
||||||
"email": userCtx.Email,
|
"email": userCtx.Email,
|
||||||
@@ -269,6 +273,10 @@ func (a *DatabaseAuthenticator) oauth2GetOrCreateUser(ctx context.Context, userC
|
|||||||
|
|
||||||
// oauth2CreateSession creates a new OAuth2 session using stored procedure
|
// oauth2CreateSession creates a new OAuth2 session using stored procedure
|
||||||
func (a *DatabaseAuthenticator) oauth2CreateSession(ctx context.Context, sessionToken string, userID int, token *oauth2.Token, expiresAt time.Time, providerName string) error {
|
func (a *DatabaseAuthenticator) oauth2CreateSession(ctx context.Context, sessionToken string, userID int, token *oauth2.Token, expiresAt time.Time, providerName string) error {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthCreateSession) {
|
||||||
|
return a.oauth2CreateSessionDirect(ctx, sessionToken, userID, token, expiresAt, providerName)
|
||||||
|
}
|
||||||
|
|
||||||
sessionData := map[string]interface{}{
|
sessionData := map[string]interface{}{
|
||||||
"session_token": sessionToken,
|
"session_token": sessionToken,
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
@@ -381,35 +389,9 @@ func (a *DatabaseAuthenticator) OAuth2RefreshToken(ctx context.Context, refreshT
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get session by refresh token from database
|
// Get session by refresh token from database
|
||||||
var success bool
|
session, err := a.oauthGetByRefreshToken(ctx, refreshToken)
|
||||||
var errMsg *string
|
|
||||||
var sessionData []byte
|
|
||||||
|
|
||||||
err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
|
|
||||||
SELECT p_success, p_error, p_data::text
|
|
||||||
FROM %s($1)
|
|
||||||
`, a.sqlNames.OAuthGetRefreshToken), refreshToken).Scan(&success, &errMsg, &sessionData)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get session by refresh token: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
|
|
||||||
if !success {
|
|
||||||
if errMsg != nil {
|
|
||||||
return nil, fmt.Errorf("%s", *errMsg)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("invalid or expired refresh token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse session data
|
|
||||||
var session struct {
|
|
||||||
UserID int `json:"user_id"`
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
TokenType string `json:"token_type"`
|
|
||||||
Expiry time.Time `json:"expiry"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(sessionData, &session); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse session data: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create oauth2.Token from stored data
|
// Create oauth2.Token from stored data
|
||||||
@@ -434,64 +416,14 @@ func (a *DatabaseAuthenticator) OAuth2RefreshToken(ctx context.Context, refreshT
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update session in database with new tokens
|
// Update session in database with new tokens
|
||||||
updateData := map[string]interface{}{
|
if err := a.oauthUpdateRefreshTokenRecord(ctx, session.UserID, refreshToken, newSessionToken, newToken.AccessToken, newToken.RefreshToken, newToken.Expiry); err != nil {
|
||||||
"user_id": session.UserID,
|
return nil, err
|
||||||
"old_refresh_token": refreshToken,
|
|
||||||
"new_session_token": newSessionToken,
|
|
||||||
"new_access_token": newToken.AccessToken,
|
|
||||||
"new_refresh_token": newToken.RefreshToken,
|
|
||||||
"expires_at": newToken.Expiry,
|
|
||||||
}
|
|
||||||
|
|
||||||
updateJSON, err := json.Marshal(updateData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to marshal update data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var updateSuccess bool
|
|
||||||
var updateErrMsg *string
|
|
||||||
|
|
||||||
err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
|
|
||||||
SELECT p_success, p_error
|
|
||||||
FROM %s($1::jsonb)
|
|
||||||
`, a.sqlNames.OAuthUpdateRefreshToken), updateJSON).Scan(&updateSuccess, &updateErrMsg)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to update session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !updateSuccess {
|
|
||||||
if updateErrMsg != nil {
|
|
||||||
return nil, fmt.Errorf("%s", *updateErrMsg)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("failed to update session")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user data
|
// Get user data
|
||||||
var userSuccess bool
|
userCtx, err := a.oauthGetUserByID(ctx, session.UserID)
|
||||||
var userErrMsg *string
|
|
||||||
var userData []byte
|
|
||||||
|
|
||||||
err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
|
|
||||||
SELECT p_success, p_error, p_data::text
|
|
||||||
FROM %s($1)
|
|
||||||
`, a.sqlNames.OAuthGetUser), session.UserID).Scan(&userSuccess, &userErrMsg, &userData)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get user data: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
|
|
||||||
if !userSuccess {
|
|
||||||
if userErrMsg != nil {
|
|
||||||
return nil, fmt.Errorf("%s", *userErrMsg)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("failed to get user data")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse user context
|
|
||||||
var userCtx UserContext
|
|
||||||
if err := json.Unmarshal(userData, &userCtx); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse user context: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
userCtx.SessionID = newSessionToken
|
userCtx.SessionID = newSessionToken
|
||||||
@@ -499,7 +431,7 @@ func (a *DatabaseAuthenticator) OAuth2RefreshToken(ctx context.Context, refreshT
|
|||||||
return &LoginResponse{
|
return &LoginResponse{
|
||||||
Token: newSessionToken,
|
Token: newSessionToken,
|
||||||
RefreshToken: newToken.RefreshToken,
|
RefreshToken: newToken.RefreshToken,
|
||||||
User: &userCtx,
|
User: userCtx,
|
||||||
ExpiresIn: int64(time.Until(newToken.Expiry).Seconds()),
|
ExpiresIn: int64(time.Until(newToken.Expiry).Seconds()),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+242
@@ -0,0 +1,242 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// oauthRefreshSession is the session data needed to refresh an OAuth2 token,
|
||||||
|
// shared by both the stored-procedure and Direct-mode code paths.
|
||||||
|
type oauthRefreshSession struct {
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
Expiry time.Time `json:"expiry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauth2GetOrCreateUserDirect mirrors resolvespec_oauth_getorcreateuser.
|
||||||
|
func (a *DatabaseAuthenticator) oauth2GetOrCreateUserDirect(ctx context.Context, userCtx *UserContext, providerName string) (int, error) {
|
||||||
|
rolesStr := strings.Join(userCtx.Roles, ",")
|
||||||
|
var userID int
|
||||||
|
|
||||||
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE email = ?`, a.tableNames.Users))
|
||||||
|
err := db.QueryRowContext(ctx, query, userCtx.Email).Scan(&userID)
|
||||||
|
if err == nil {
|
||||||
|
now := time.Now()
|
||||||
|
updQuery := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`UPDATE %s SET last_login_at = ?, updated_at = ?, remote_id = COALESCE(remote_id, ?), auth_provider = COALESCE(auth_provider, ?) WHERE id = ?`,
|
||||||
|
a.tableNames.Users))
|
||||||
|
_, err := db.ExecContext(ctx, updQuery, now, now, userCtx.RemoteID, providerName, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
insQuery := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`INSERT INTO %s (username, email, password, user_level, roles, is_active, created_at, updated_at, last_login_at, remote_id, auth_provider) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
a.tableNames.Users))
|
||||||
|
res, err := db.ExecContext(ctx, insQuery, userCtx.UserName, userCtx.Email, userCtx.UserLevel, rolesStr, true, now, now, now, userCtx.RemoteID, providerName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userID = int(id)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to get or create user: %w", err)
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauth2CreateSessionDirect mirrors resolvespec_oauth_createsession (insert-or-update by session_token).
|
||||||
|
func (a *DatabaseAuthenticator) oauth2CreateSessionDirect(ctx context.Context, sessionToken string, userID int, token *oauth2.Token, expiresAt time.Time, providerName string) error {
|
||||||
|
return a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
var exists int
|
||||||
|
checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT 1 FROM %s WHERE session_token = ?`, a.tableNames.UserSessions))
|
||||||
|
err := db.QueryRowContext(ctx, checkQuery, sessionToken).Scan(&exists)
|
||||||
|
now := time.Now()
|
||||||
|
if err == nil {
|
||||||
|
updQuery := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`UPDATE %s SET access_token = ?, refresh_token = ?, token_type = ?, expires_at = ?, last_activity_at = ? WHERE session_token = ?`,
|
||||||
|
a.tableNames.UserSessions))
|
||||||
|
_, err := db.ExecContext(ctx, updQuery, token.AccessToken, token.RefreshToken, token.TokenType, expiresAt, now, sessionToken)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
insQuery := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`INSERT INTO %s (session_token, user_id, expires_at, created_at, last_activity_at, access_token, refresh_token, token_type, auth_provider) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
a.tableNames.UserSessions))
|
||||||
|
_, err = db.ExecContext(ctx, insQuery, sessionToken, userID, expiresAt, now, now, token.AccessToken, token.RefreshToken, token.TokenType, providerName)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauthGetByRefreshToken retrieves the session for a refresh token, dispatching between
|
||||||
|
// the resolvespec_oauth_getrefreshtoken stored procedure and Direct-mode SQL.
|
||||||
|
func (a *DatabaseAuthenticator) oauthGetByRefreshToken(ctx context.Context, refreshToken string) (*oauthRefreshSession, error) {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthGetRefreshToken) {
|
||||||
|
var session oauthRefreshSession
|
||||||
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`SELECT user_id, access_token, token_type, expires_at FROM %s WHERE refresh_token = ? AND expires_at > ?`,
|
||||||
|
a.tableNames.UserSessions))
|
||||||
|
return db.QueryRowContext(ctx, query, refreshToken, time.Now()).Scan(&session.UserID, &session.AccessToken, &session.TokenType, &session.Expiry)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("refresh token not found or expired")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get session by refresh token: %w", err)
|
||||||
|
}
|
||||||
|
return &session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var success bool
|
||||||
|
var errMsg *string
|
||||||
|
var sessionData []byte
|
||||||
|
|
||||||
|
err := a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
|
||||||
|
SELECT p_success, p_error, p_data::text
|
||||||
|
FROM %s($1)
|
||||||
|
`, a.sqlNames.OAuthGetRefreshToken), refreshToken).Scan(&success, &errMsg, &sessionData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get session by refresh token: %w", err)
|
||||||
|
}
|
||||||
|
if !success {
|
||||||
|
if errMsg != nil {
|
||||||
|
return nil, fmt.Errorf("%s", *errMsg)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("invalid or expired refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var session oauthRefreshSession
|
||||||
|
if err := json.Unmarshal(sessionData, &session); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse session data: %w", err)
|
||||||
|
}
|
||||||
|
return &session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauthUpdateRefreshTokenRecord updates a session with new tokens, dispatching between
|
||||||
|
// the resolvespec_oauth_updaterefreshtoken stored procedure and Direct-mode SQL.
|
||||||
|
func (a *DatabaseAuthenticator) oauthUpdateRefreshTokenRecord(ctx context.Context, userID int, oldRefreshToken, newSessionToken, newAccessToken, newRefreshToken string, expiresAt time.Time) error {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthUpdateRefreshToken) {
|
||||||
|
var rows int64
|
||||||
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`UPDATE %s SET session_token = ?, access_token = ?, refresh_token = ?, expires_at = ?, last_activity_at = ? WHERE user_id = ? AND refresh_token = ?`,
|
||||||
|
a.tableNames.UserSessions))
|
||||||
|
res, err := db.ExecContext(ctx, query, newSessionToken, newAccessToken, newRefreshToken, expiresAt, time.Now(), userID, oldRefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err = res.RowsAffected()
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update session: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("session not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData := map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"old_refresh_token": oldRefreshToken,
|
||||||
|
"new_session_token": newSessionToken,
|
||||||
|
"new_access_token": newAccessToken,
|
||||||
|
"new_refresh_token": newRefreshToken,
|
||||||
|
"expires_at": expiresAt,
|
||||||
|
}
|
||||||
|
updateJSON, err := json.Marshal(updateData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal update data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateSuccess bool
|
||||||
|
var updateErrMsg *string
|
||||||
|
err = a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
|
||||||
|
SELECT p_success, p_error
|
||||||
|
FROM %s($1::jsonb)
|
||||||
|
`, a.sqlNames.OAuthUpdateRefreshToken), updateJSON).Scan(&updateSuccess, &updateErrMsg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update session: %w", err)
|
||||||
|
}
|
||||||
|
if !updateSuccess {
|
||||||
|
if updateErrMsg != nil {
|
||||||
|
return fmt.Errorf("%s", *updateErrMsg)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to update session")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauthGetUserByID retrieves user data by ID, dispatching between the
|
||||||
|
// resolvespec_oauth_getuser stored procedure and Direct-mode SQL.
|
||||||
|
func (a *DatabaseAuthenticator) oauthGetUserByID(ctx context.Context, userID int) (*UserContext, error) {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthGetUser) {
|
||||||
|
var username, email, roles, programUserTable sql.NullString
|
||||||
|
var userLevel, programUserID sql.NullInt64
|
||||||
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`SELECT username, email, user_level, roles, program_user_id, program_user_table FROM %s WHERE id = ? AND is_active = ?`,
|
||||||
|
a.tableNames.Users))
|
||||||
|
return db.QueryRowContext(ctx, query, userID, true).Scan(&username, &email, &userLevel, &roles, &programUserID, &programUserTable)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get user data: %w", err)
|
||||||
|
}
|
||||||
|
return &UserContext{
|
||||||
|
UserID: userID,
|
||||||
|
UserName: username.String,
|
||||||
|
Email: email.String,
|
||||||
|
UserLevel: int(userLevel.Int64),
|
||||||
|
Roles: parseRoles(roles.String),
|
||||||
|
ProgramUserID: int(programUserID.Int64),
|
||||||
|
ProgramUserTable: programUserTable.String,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var userSuccess bool
|
||||||
|
var userErrMsg *string
|
||||||
|
var userData []byte
|
||||||
|
err := a.getDB().QueryRowContext(ctx, fmt.Sprintf(`
|
||||||
|
SELECT p_success, p_error, p_data::text
|
||||||
|
FROM %s($1)
|
||||||
|
`, a.sqlNames.OAuthGetUser), userID).Scan(&userSuccess, &userErrMsg, &userData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get user data: %w", err)
|
||||||
|
}
|
||||||
|
if !userSuccess {
|
||||||
|
if userErrMsg != nil {
|
||||||
|
return nil, fmt.Errorf("%s", *userErrMsg)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get user data")
|
||||||
|
}
|
||||||
|
var userCtx UserContext
|
||||||
|
if err := json.Unmarshal(userData, &userCtx); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse user context: %w", err)
|
||||||
|
}
|
||||||
|
return &userCtx, nil
|
||||||
|
}
|
||||||
+24
@@ -44,6 +44,10 @@ type OAuthTokenInfo struct {
|
|||||||
|
|
||||||
// OAuthRegisterClient persists an OAuth2 client registration.
|
// OAuthRegisterClient persists an OAuth2 client registration.
|
||||||
func (a *DatabaseAuthenticator) OAuthRegisterClient(ctx context.Context, client *OAuthServerClient) (*OAuthServerClient, error) {
|
func (a *DatabaseAuthenticator) OAuthRegisterClient(ctx context.Context, client *OAuthServerClient) (*OAuthServerClient, error) {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthRegisterClient) {
|
||||||
|
return a.oauthRegisterClientDirect(ctx, client)
|
||||||
|
}
|
||||||
|
|
||||||
input, err := json.Marshal(client)
|
input, err := json.Marshal(client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to marshal client: %w", err)
|
return nil, fmt.Errorf("failed to marshal client: %w", err)
|
||||||
@@ -76,6 +80,10 @@ func (a *DatabaseAuthenticator) OAuthRegisterClient(ctx context.Context, client
|
|||||||
|
|
||||||
// OAuthGetClient retrieves a registered client by ID.
|
// OAuthGetClient retrieves a registered client by ID.
|
||||||
func (a *DatabaseAuthenticator) OAuthGetClient(ctx context.Context, clientID string) (*OAuthServerClient, error) {
|
func (a *DatabaseAuthenticator) OAuthGetClient(ctx context.Context, clientID string) (*OAuthServerClient, error) {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthGetClient) {
|
||||||
|
return a.oauthGetClientDirect(ctx, clientID)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errMsg *string
|
var errMsg *string
|
||||||
var data []byte
|
var data []byte
|
||||||
@@ -103,6 +111,10 @@ func (a *DatabaseAuthenticator) OAuthGetClient(ctx context.Context, clientID str
|
|||||||
|
|
||||||
// OAuthSaveCode persists an authorization code.
|
// OAuthSaveCode persists an authorization code.
|
||||||
func (a *DatabaseAuthenticator) OAuthSaveCode(ctx context.Context, code *OAuthCode) error {
|
func (a *DatabaseAuthenticator) OAuthSaveCode(ctx context.Context, code *OAuthCode) error {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthSaveCode) {
|
||||||
|
return a.oauthSaveCodeDirect(ctx, code)
|
||||||
|
}
|
||||||
|
|
||||||
input, err := json.Marshal(code)
|
input, err := json.Marshal(code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal code: %w", err)
|
return fmt.Errorf("failed to marshal code: %w", err)
|
||||||
@@ -129,6 +141,10 @@ func (a *DatabaseAuthenticator) OAuthSaveCode(ctx context.Context, code *OAuthCo
|
|||||||
|
|
||||||
// OAuthExchangeCode retrieves and deletes an authorization code (single use).
|
// OAuthExchangeCode retrieves and deletes an authorization code (single use).
|
||||||
func (a *DatabaseAuthenticator) OAuthExchangeCode(ctx context.Context, code string) (*OAuthCode, error) {
|
func (a *DatabaseAuthenticator) OAuthExchangeCode(ctx context.Context, code string) (*OAuthCode, error) {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthExchangeCode) {
|
||||||
|
return a.oauthExchangeCodeDirect(ctx, code)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errMsg *string
|
var errMsg *string
|
||||||
var data []byte
|
var data []byte
|
||||||
@@ -157,6 +173,10 @@ func (a *DatabaseAuthenticator) OAuthExchangeCode(ctx context.Context, code stri
|
|||||||
|
|
||||||
// OAuthIntrospectToken validates a token and returns its metadata (RFC 7662).
|
// OAuthIntrospectToken validates a token and returns its metadata (RFC 7662).
|
||||||
func (a *DatabaseAuthenticator) OAuthIntrospectToken(ctx context.Context, token string) (*OAuthTokenInfo, error) {
|
func (a *DatabaseAuthenticator) OAuthIntrospectToken(ctx context.Context, token string) (*OAuthTokenInfo, error) {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthIntrospect) {
|
||||||
|
return a.oauthIntrospectTokenDirect(ctx, token)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errMsg *string
|
var errMsg *string
|
||||||
var data []byte
|
var data []byte
|
||||||
@@ -184,6 +204,10 @@ func (a *DatabaseAuthenticator) OAuthIntrospectToken(ctx context.Context, token
|
|||||||
|
|
||||||
// OAuthRevokeToken revokes a token by deleting the session (RFC 7009).
|
// OAuthRevokeToken revokes a token by deleting the session (RFC 7009).
|
||||||
func (a *DatabaseAuthenticator) OAuthRevokeToken(ctx context.Context, token string) error {
|
func (a *DatabaseAuthenticator) OAuthRevokeToken(ctx context.Context, token string) error {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.OAuthRevoke) {
|
||||||
|
return a.oauthRevokeTokenDirect(ctx, token)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errMsg *string
|
var errMsg *string
|
||||||
|
|
||||||
|
|||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Direct-mode implementations mirroring the OAuth2 server stored procedures
|
||||||
|
// (resolvespec_oauth_register_client, etc.) in database_schema.sql, using
|
||||||
|
// plain SQL against TableNames.OAuthClients / TableNames.OAuthCodes.
|
||||||
|
// Array columns (redirect_uris, grant_types, allowed_scopes, scopes) are
|
||||||
|
// JSON-encoded TEXT instead of native Postgres arrays.
|
||||||
|
|
||||||
|
func (a *DatabaseAuthenticator) oauthRegisterClientDirect(ctx context.Context, client *OAuthServerClient) (*OAuthServerClient, error) {
|
||||||
|
grantTypes := client.GrantTypes
|
||||||
|
if len(grantTypes) == 0 {
|
||||||
|
grantTypes = []string{"authorization_code"}
|
||||||
|
}
|
||||||
|
allowedScopes := client.AllowedScopes
|
||||||
|
if len(allowedScopes) == 0 {
|
||||||
|
allowedScopes = []string{"openid", "profile", "email"}
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectURIsJSON, err := json.Marshal(client.RedirectURIs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal redirect_uris: %w", err)
|
||||||
|
}
|
||||||
|
grantTypesJSON, err := json.Marshal(grantTypes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal grant_types: %w", err)
|
||||||
|
}
|
||||||
|
allowedScopesJSON, err := json.Marshal(allowedScopes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal allowed_scopes: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`INSERT INTO %s (client_id, redirect_uris, client_name, grant_types, allowed_scopes, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
a.tableNames.OAuthClients))
|
||||||
|
_, err := db.ExecContext(ctx, query, client.ClientID, string(redirectURIsJSON), client.ClientName, string(grantTypesJSON), string(allowedScopesJSON), true, time.Now())
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to register client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &OAuthServerClient{
|
||||||
|
ClientID: client.ClientID,
|
||||||
|
RedirectURIs: client.RedirectURIs,
|
||||||
|
ClientName: client.ClientName,
|
||||||
|
GrantTypes: grantTypes,
|
||||||
|
AllowedScopes: allowedScopes,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DatabaseAuthenticator) oauthGetClientDirect(ctx context.Context, clientID string) (*OAuthServerClient, error) {
|
||||||
|
var redirectURIsJSON, grantTypesJSON, allowedScopesJSON sql.NullString
|
||||||
|
var clientName sql.NullString
|
||||||
|
|
||||||
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`SELECT redirect_uris, client_name, grant_types, allowed_scopes FROM %s WHERE client_id = ? AND is_active = ?`,
|
||||||
|
a.tableNames.OAuthClients))
|
||||||
|
return db.QueryRowContext(ctx, query, clientID, true).Scan(&redirectURIsJSON, &clientName, &grantTypesJSON, &allowedScopesJSON)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("client not found")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &OAuthServerClient{ClientID: clientID, ClientName: clientName.String}
|
||||||
|
if redirectURIsJSON.Valid {
|
||||||
|
_ = json.Unmarshal([]byte(redirectURIsJSON.String), &result.RedirectURIs)
|
||||||
|
}
|
||||||
|
if grantTypesJSON.Valid {
|
||||||
|
_ = json.Unmarshal([]byte(grantTypesJSON.String), &result.GrantTypes)
|
||||||
|
}
|
||||||
|
if allowedScopesJSON.Valid {
|
||||||
|
_ = json.Unmarshal([]byte(allowedScopesJSON.String), &result.AllowedScopes)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DatabaseAuthenticator) oauthSaveCodeDirect(ctx context.Context, code *OAuthCode) error {
|
||||||
|
scopesJSON, err := json.Marshal(code.Scopes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal scopes: %w", err)
|
||||||
|
}
|
||||||
|
method := code.CodeChallengeMethod
|
||||||
|
if method == "" {
|
||||||
|
method = "S256"
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`INSERT INTO %s (code, client_id, redirect_uri, client_state, code_challenge, code_challenge_method, session_token, refresh_token, scopes, expires_at, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, a.tableNames.OAuthCodes))
|
||||||
|
_, err := db.ExecContext(ctx, query, code.Code, code.ClientID, code.RedirectURI, code.ClientState, code.CodeChallenge,
|
||||||
|
method, code.SessionToken, code.RefreshToken, string(scopesJSON), code.ExpiresAt, time.Now())
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DatabaseAuthenticator) oauthExchangeCodeDirect(ctx context.Context, code string) (*OAuthCode, error) {
|
||||||
|
var result OAuthCode
|
||||||
|
var clientState, refreshToken sql.NullString
|
||||||
|
var scopesJSON sql.NullString
|
||||||
|
|
||||||
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`SELECT client_id, redirect_uri, client_state, code_challenge, code_challenge_method, session_token, refresh_token, scopes
|
||||||
|
FROM %s WHERE code = ? AND expires_at > ?`, a.tableNames.OAuthCodes))
|
||||||
|
err := db.QueryRowContext(ctx, query, code, time.Now()).Scan(
|
||||||
|
&result.ClientID, &result.RedirectURI, &clientState, &result.CodeChallenge, &result.CodeChallengeMethod, &result.SessionToken, &refreshToken, &scopesJSON)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE code = ?`, a.tableNames.OAuthCodes))
|
||||||
|
_, err = db.ExecContext(ctx, delQuery, code)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("invalid or expired code")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Code = code
|
||||||
|
result.ClientState = clientState.String
|
||||||
|
result.RefreshToken = refreshToken.String
|
||||||
|
if scopesJSON.Valid {
|
||||||
|
_ = json.Unmarshal([]byte(scopesJSON.String), &result.Scopes)
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DatabaseAuthenticator) oauthIntrospectTokenDirect(ctx context.Context, token string) (*OAuthTokenInfo, error) {
|
||||||
|
var info OAuthTokenInfo
|
||||||
|
var roles sql.NullString
|
||||||
|
var exp, iat sql.NullTime
|
||||||
|
|
||||||
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`SELECT u.id, u.username, u.email, u.user_level, u.roles, s.expires_at, s.created_at
|
||||||
|
FROM %s s JOIN %s u ON u.id = s.user_id
|
||||||
|
WHERE s.session_token = ? AND s.expires_at > ? AND u.is_active = ?`,
|
||||||
|
a.tableNames.UserSessions, a.tableNames.Users))
|
||||||
|
var userID int
|
||||||
|
err := db.QueryRowContext(ctx, query, token, time.Now(), true).Scan(&userID, &info.Username, &info.Email, &info.UserLevel, &roles, &exp, &iat)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info.Sub = fmt.Sprintf("%d", userID)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return &OAuthTokenInfo{Active: false}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to introspect token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Active = true
|
||||||
|
info.Roles = parseRoles(roles.String)
|
||||||
|
if exp.Valid {
|
||||||
|
info.Exp = exp.Time.Unix()
|
||||||
|
}
|
||||||
|
if iat.Valid {
|
||||||
|
info.Iat = iat.Time.Unix()
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DatabaseAuthenticator) oauthRevokeTokenDirect(ctx context.Context, token string) error {
|
||||||
|
return a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE session_token = ?`, a.tableNames.UserSessions))
|
||||||
|
_, err := db.ExecContext(ctx, query, token)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
+100
-7
@@ -22,6 +22,9 @@ type DatabasePasskeyProvider struct {
|
|||||||
rpOrigin string // Expected origin for WebAuthn
|
rpOrigin string // Expected origin for WebAuthn
|
||||||
timeout int64 // Timeout in milliseconds (default: 60000)
|
timeout int64 // Timeout in milliseconds (default: 60000)
|
||||||
sqlNames *SQLNames
|
sqlNames *SQLNames
|
||||||
|
tableNames *TableNames
|
||||||
|
queryMode QueryMode
|
||||||
|
capability *dbCapability
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabasePasskeyProviderOptions configures the passkey provider
|
// DatabasePasskeyProviderOptions configures the passkey provider
|
||||||
@@ -36,6 +39,10 @@ type DatabasePasskeyProviderOptions struct {
|
|||||||
Timeout int64
|
Timeout int64
|
||||||
// SQLNames provides custom SQL procedure/function names. If nil, uses DefaultSQLNames().
|
// SQLNames provides custom SQL procedure/function names. If nil, uses DefaultSQLNames().
|
||||||
SQLNames *SQLNames
|
SQLNames *SQLNames
|
||||||
|
// TableNames provides custom table names for Direct mode. If nil, uses DefaultTableNames().
|
||||||
|
TableNames *TableNames
|
||||||
|
// QueryMode selects stored-procedure vs Direct-mode SQL. Default (zero value) is ModeAuto.
|
||||||
|
QueryMode QueryMode
|
||||||
// DBFactory is called to obtain a fresh *sql.DB when the existing connection is closed.
|
// DBFactory is called to obtain a fresh *sql.DB when the existing connection is closed.
|
||||||
// If nil, reconnection is disabled.
|
// If nil, reconnection is disabled.
|
||||||
DBFactory func() (*sql.DB, error)
|
DBFactory func() (*sql.DB, error)
|
||||||
@@ -48,6 +55,7 @@ func NewDatabasePasskeyProvider(db *sql.DB, opts DatabasePasskeyProviderOptions)
|
|||||||
}
|
}
|
||||||
|
|
||||||
sqlNames := MergeSQLNames(DefaultSQLNames(), opts.SQLNames)
|
sqlNames := MergeSQLNames(DefaultSQLNames(), opts.SQLNames)
|
||||||
|
tableNames := resolveTableNames(opts.TableNames)
|
||||||
|
|
||||||
return &DatabasePasskeyProvider{
|
return &DatabasePasskeyProvider{
|
||||||
db: db,
|
db: db,
|
||||||
@@ -57,6 +65,9 @@ func NewDatabasePasskeyProvider(db *sql.DB, opts DatabasePasskeyProviderOptions)
|
|||||||
rpOrigin: opts.RPOrigin,
|
rpOrigin: opts.RPOrigin,
|
||||||
timeout: opts.Timeout,
|
timeout: opts.Timeout,
|
||||||
sqlNames: sqlNames,
|
sqlNames: sqlNames,
|
||||||
|
tableNames: tableNames,
|
||||||
|
queryMode: opts.QueryMode,
|
||||||
|
capability: newDBCapability(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +88,26 @@ func (p *DatabasePasskeyProvider) reconnectDB() error {
|
|||||||
p.dbMu.Lock()
|
p.dbMu.Lock()
|
||||||
p.db = newDB
|
p.db = newDB
|
||||||
p.dbMu.Unlock()
|
p.dbMu.Unlock()
|
||||||
|
if p.capability != nil {
|
||||||
|
p.capability.reset()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *DatabasePasskeyProvider) runDBOpWithReconnect(run func(*sql.DB) error) error {
|
||||||
|
db := p.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return fmt.Errorf("database connection is nil")
|
||||||
|
}
|
||||||
|
err := run(db)
|
||||||
|
if isDBClosed(err) {
|
||||||
|
if reconnErr := p.reconnectDB(); reconnErr == nil {
|
||||||
|
err = run(p.getDB())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// BeginRegistration creates registration options for a new passkey
|
// BeginRegistration creates registration options for a new passkey
|
||||||
func (p *DatabasePasskeyProvider) BeginRegistration(ctx context.Context, userID int, username, displayName string) (*PasskeyRegistrationOptions, error) {
|
func (p *DatabasePasskeyProvider) BeginRegistration(ctx context.Context, userID int, username, displayName string) (*PasskeyRegistrationOptions, error) {
|
||||||
// Generate challenge
|
// Generate challenge
|
||||||
@@ -145,10 +173,40 @@ func (p *DatabasePasskeyProvider) CompleteRegistration(ctx context.Context, user
|
|||||||
// For now, this is a placeholder that stores the credential data
|
// For now, this is a placeholder that stores the credential data
|
||||||
// In production, you MUST use a proper WebAuthn library
|
// In production, you MUST use a proper WebAuthn library
|
||||||
|
|
||||||
|
credIDB64 := base64.StdEncoding.EncodeToString(response.RawID)
|
||||||
|
pubKeyB64 := base64.StdEncoding.EncodeToString(response.Response.AttestationObject)
|
||||||
|
|
||||||
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyStoreCredential) {
|
||||||
|
credentialID, err := p.storeCredentialDirect(ctx, storeCredentialParams{
|
||||||
|
UserID: userID,
|
||||||
|
CredentialID: credIDB64,
|
||||||
|
PublicKey: pubKeyB64,
|
||||||
|
AttestationType: "none",
|
||||||
|
SignCount: 0,
|
||||||
|
Transports: response.Transports,
|
||||||
|
BackupEligible: false,
|
||||||
|
BackupState: false,
|
||||||
|
Name: "Passkey",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PasskeyCredential{
|
||||||
|
ID: fmt.Sprintf("%d", credentialID),
|
||||||
|
UserID: userID,
|
||||||
|
CredentialID: response.RawID,
|
||||||
|
PublicKey: response.Response.AttestationObject,
|
||||||
|
AttestationType: "none",
|
||||||
|
Transports: response.Transports,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
LastUsedAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
credData := map[string]any{
|
credData := map[string]any{
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"credential_id": base64.StdEncoding.EncodeToString(response.RawID),
|
"credential_id": credIDB64,
|
||||||
"public_key": base64.StdEncoding.EncodeToString(response.Response.AttestationObject),
|
"public_key": pubKeyB64,
|
||||||
"attestation_type": "none",
|
"attestation_type": "none",
|
||||||
"sign_count": 0,
|
"sign_count": 0,
|
||||||
"transports": response.Transports,
|
"transports": response.Transports,
|
||||||
@@ -202,6 +260,15 @@ func (p *DatabasePasskeyProvider) BeginAuthentication(ctx context.Context, usern
|
|||||||
// If username is provided, get user's credentials
|
// If username is provided, get user's credentials
|
||||||
var allowCredentials []PasskeyCredentialDescriptor
|
var allowCredentials []PasskeyCredentialDescriptor
|
||||||
if username != "" {
|
if username != "" {
|
||||||
|
var creds []passkeyCredential
|
||||||
|
|
||||||
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetCredsByUsername) {
|
||||||
|
_, directCreds, err := p.getCredsByUsernameDirect(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
creds = directCreds
|
||||||
|
} else {
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
var userID sql.NullInt64
|
var userID sql.NullInt64
|
||||||
@@ -220,14 +287,10 @@ func (p *DatabasePasskeyProvider) BeginAuthentication(ctx context.Context, usern
|
|||||||
return nil, fmt.Errorf("failed to get credentials")
|
return nil, fmt.Errorf("failed to get credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse credentials
|
|
||||||
var creds []struct {
|
|
||||||
ID string `json:"credential_id"`
|
|
||||||
Transports []string `json:"transports"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(credentialsJSON.String), &creds); err != nil {
|
if err := json.Unmarshal([]byte(credentialsJSON.String), &creds); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse credentials: %w", err)
|
return nil, fmt.Errorf("failed to parse credentials: %w", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
allowCredentials = make([]PasskeyCredentialDescriptor, 0, len(creds))
|
allowCredentials = make([]PasskeyCredentialDescriptor, 0, len(creds))
|
||||||
for _, cred := range creds {
|
for _, cred := range creds {
|
||||||
@@ -262,6 +325,24 @@ func (p *DatabasePasskeyProvider) CompleteAuthentication(ctx context.Context, re
|
|||||||
// 3. Verify signature using stored public key
|
// 3. Verify signature using stored public key
|
||||||
// 4. Update sign counter and check for cloning
|
// 4. Update sign counter and check for cloning
|
||||||
|
|
||||||
|
credIDB64 := base64.StdEncoding.EncodeToString(response.RawID)
|
||||||
|
|
||||||
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetCredential) {
|
||||||
|
userID, signCount, err := p.getCredentialDirect(ctx, credIDB64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
newCounter := signCount + 1
|
||||||
|
cloneWarning, err := p.updateCounterDirect(ctx, credIDB64, newCounter)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to update counter: %w", err)
|
||||||
|
}
|
||||||
|
if cloneWarning {
|
||||||
|
return 0, fmt.Errorf("credential cloning detected")
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Get credential from database
|
// Get credential from database
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
@@ -321,6 +402,10 @@ func (p *DatabasePasskeyProvider) CompleteAuthentication(ctx context.Context, re
|
|||||||
|
|
||||||
// GetCredentials returns all passkey credentials for a user
|
// GetCredentials returns all passkey credentials for a user
|
||||||
func (p *DatabasePasskeyProvider) GetCredentials(ctx context.Context, userID int) ([]PasskeyCredential, error) {
|
func (p *DatabasePasskeyProvider) GetCredentials(ctx context.Context, userID int) ([]PasskeyCredential, error) {
|
||||||
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyGetUserCredentials) {
|
||||||
|
return p.getUserCredentialsDirect(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
var credentialsJSON sql.NullString
|
var credentialsJSON sql.NullString
|
||||||
@@ -401,6 +486,10 @@ func (p *DatabasePasskeyProvider) DeleteCredential(ctx context.Context, userID i
|
|||||||
return fmt.Errorf("invalid credential ID: %w", err)
|
return fmt.Errorf("invalid credential ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyDeleteCredential) {
|
||||||
|
return p.deleteCredentialDirect(ctx, userID, credentialID)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
|
|
||||||
@@ -427,6 +516,10 @@ func (p *DatabasePasskeyProvider) UpdateCredentialName(ctx context.Context, user
|
|||||||
return fmt.Errorf("invalid credential ID: %w", err)
|
return fmt.Errorf("invalid credential ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.PasskeyUpdateName) {
|
||||||
|
return p.updateNameDirect(ctx, userID, credentialID, name)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
|
|
||||||
|
|||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Direct-mode implementations mirroring the resolvespec_passkey_* stored
|
||||||
|
// procedures in database_schema.sql using plain SQL against TableNames.
|
||||||
|
// credential_id/public_key/aaguid are stored as base64 TEXT (not native
|
||||||
|
// bytea) and transports as JSON-encoded TEXT, so the same schema works on
|
||||||
|
// SQLite, MySQL, and Postgres.
|
||||||
|
|
||||||
|
type storeCredentialParams struct {
|
||||||
|
UserID int
|
||||||
|
CredentialID string // base64
|
||||||
|
PublicKey string // base64
|
||||||
|
AttestationType string
|
||||||
|
SignCount int
|
||||||
|
Transports []string
|
||||||
|
BackupEligible bool
|
||||||
|
BackupState bool
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DatabasePasskeyProvider) storeCredentialDirect(ctx context.Context, params storeCredentialParams) (int64, error) {
|
||||||
|
transportsJSON, err := json.Marshal(params.Transports)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to marshal transports: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var credentialID int64
|
||||||
|
err = p.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
var exists int
|
||||||
|
checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT 1 FROM %s WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials))
|
||||||
|
if err := db.QueryRowContext(ctx, checkQuery, params.CredentialID).Scan(&exists); err == nil {
|
||||||
|
return fmt.Errorf("credential already exists")
|
||||||
|
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var userExists int
|
||||||
|
userCheckQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT 1 FROM %s WHERE id = ?`, p.tableNames.Users))
|
||||||
|
if err := db.QueryRowContext(ctx, userCheckQuery, params.UserID).Scan(&userExists); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
insertQuery := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`INSERT INTO %s (user_id, credential_id, public_key, attestation_type, aaguid, sign_count, transports, backup_eligible, backup_state, name, created_at, last_used_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, p.tableNames.UserPasskeyCredentials))
|
||||||
|
res, err := db.ExecContext(ctx, insertQuery, params.UserID, params.CredentialID, params.PublicKey, params.AttestationType,
|
||||||
|
"", params.SignCount, string(transportsJSON), params.BackupEligible, params.BackupState, params.Name, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
credentialID, err = res.LastInsertId()
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return credentialID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DatabasePasskeyProvider) getCredentialDirect(ctx context.Context, credentialIDB64 string) (userID int, signCount uint32, err error) {
|
||||||
|
err = p.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT user_id, sign_count FROM %s WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials))
|
||||||
|
return db.QueryRowContext(ctx, query, credentialIDB64).Scan(&userID, &signCount)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, 0, fmt.Errorf("credential not found")
|
||||||
|
}
|
||||||
|
return 0, 0, fmt.Errorf("failed to get credential: %w", err)
|
||||||
|
}
|
||||||
|
return userID, signCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DatabasePasskeyProvider) updateCounterDirect(ctx context.Context, credentialIDB64 string, newCounter uint32) (cloneWarning bool, err error) {
|
||||||
|
err = p.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
var oldCounter int
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT sign_count FROM %s WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials))
|
||||||
|
if err := db.QueryRowContext(ctx, query, credentialIDB64).Scan(&oldCounter); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fmt.Errorf("credential not found")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if int(newCounter) <= oldCounter {
|
||||||
|
cloneWarning = true
|
||||||
|
updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET clone_warning = ? WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials))
|
||||||
|
_, err := db.ExecContext(ctx, updQuery, true, credentialIDB64)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET sign_count = ?, last_used_at = ? WHERE credential_id = ?`, p.tableNames.UserPasskeyCredentials))
|
||||||
|
_, err := db.ExecContext(ctx, updQuery, newCounter, time.Now(), credentialIDB64)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return cloneWarning, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DatabasePasskeyProvider) getUserCredentialsDirect(ctx context.Context, userID int) ([]PasskeyCredential, error) {
|
||||||
|
var credentials []PasskeyCredential
|
||||||
|
err := p.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`SELECT id, user_id, credential_id, public_key, attestation_type, aaguid, sign_count, clone_warning, transports, backup_eligible, backup_state, name, created_at, last_used_at
|
||||||
|
FROM %s WHERE user_id = ? ORDER BY created_at DESC`, p.tableNames.UserPasskeyCredentials))
|
||||||
|
rows, err := db.QueryContext(ctx, query, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
credentials = make([]PasskeyCredential, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var id, uid int
|
||||||
|
var credIDB64, pubKeyB64, attestationType, aaguidB64, name string
|
||||||
|
var signCount uint32
|
||||||
|
var cloneWarning, backupEligible, backupState bool
|
||||||
|
var transportsJSON sql.NullString
|
||||||
|
var createdAt, lastUsedAt time.Time
|
||||||
|
|
||||||
|
if err := rows.Scan(&id, &uid, &credIDB64, &pubKeyB64, &attestationType, &aaguidB64, &signCount,
|
||||||
|
&cloneWarning, &transportsJSON, &backupEligible, &backupState, &name, &createdAt, &lastUsedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
credID, err := base64.StdEncoding.DecodeString(credIDB64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pubKey, err := base64.StdEncoding.DecodeString(pubKeyB64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
aaguid, _ := base64.StdEncoding.DecodeString(aaguidB64)
|
||||||
|
|
||||||
|
var transports []string
|
||||||
|
if transportsJSON.Valid && transportsJSON.String != "" {
|
||||||
|
_ = json.Unmarshal([]byte(transportsJSON.String), &transports)
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials = append(credentials, PasskeyCredential{
|
||||||
|
ID: fmt.Sprintf("%d", id),
|
||||||
|
UserID: uid,
|
||||||
|
CredentialID: credID,
|
||||||
|
PublicKey: pubKey,
|
||||||
|
AttestationType: attestationType,
|
||||||
|
AAGUID: aaguid,
|
||||||
|
SignCount: signCount,
|
||||||
|
CloneWarning: cloneWarning,
|
||||||
|
Transports: transports,
|
||||||
|
BackupEligible: backupEligible,
|
||||||
|
BackupState: backupState,
|
||||||
|
Name: name,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
LastUsedAt: lastUsedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get credentials: %w", err)
|
||||||
|
}
|
||||||
|
return credentials, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DatabasePasskeyProvider) deleteCredentialDirect(ctx context.Context, userID int, credentialIDB64 string) error {
|
||||||
|
return p.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ? AND credential_id = ?`, p.tableNames.UserPasskeyCredentials))
|
||||||
|
res, err := db.ExecContext(ctx, query, userID, credentialIDB64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("credential not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DatabasePasskeyProvider) updateNameDirect(ctx context.Context, userID int, credentialIDB64, name string) error {
|
||||||
|
return p.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET name = ? WHERE user_id = ? AND credential_id = ?`, p.tableNames.UserPasskeyCredentials))
|
||||||
|
res, err := db.ExecContext(ctx, query, name, userID, credentialIDB64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("credential not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type passkeyCredential struct {
|
||||||
|
ID string `json:"credential_id"`
|
||||||
|
Transports []string `json:"transports"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DatabasePasskeyProvider) getCredsByUsernameDirect(ctx context.Context, username string) (userID int, creds []passkeyCredential, err error) {
|
||||||
|
err = p.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
userQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE username = ? AND is_active = ?`, p.tableNames.Users))
|
||||||
|
if err := db.QueryRowContext(ctx, userQuery, username, true).Scan(&userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT credential_id, transports FROM %s WHERE user_id = ?`, p.tableNames.UserPasskeyCredentials))
|
||||||
|
rows, err := db.QueryContext(ctx, query, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
creds = make([]passkeyCredential, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var credID string
|
||||||
|
var transportsJSON sql.NullString
|
||||||
|
if err := rows.Scan(&credID, &transportsJSON); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var transports []string
|
||||||
|
if transportsJSON.Valid && transportsJSON.String != "" {
|
||||||
|
_ = json.Unmarshal([]byte(transportsJSON.String), &transports)
|
||||||
|
}
|
||||||
|
creds = append(creds, passkeyCredential{ID: credID, Transports: transports})
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return 0, nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
return 0, nil, fmt.Errorf("failed to get credentials: %w", err)
|
||||||
|
}
|
||||||
|
return userID, creds, nil
|
||||||
|
}
|
||||||
+10
-7
@@ -34,7 +34,10 @@ type RowSecurity struct {
|
|||||||
Tablename string `json:"tablename"`
|
Tablename string `json:"tablename"`
|
||||||
Template string `json:"template"`
|
Template string `json:"template"`
|
||||||
HasBlock bool `json:"has_block"`
|
HasBlock bool `json:"has_block"`
|
||||||
UserID int `json:"user_id"`
|
// UserID is the opaque user reference the security rules were loaded for.
|
||||||
|
// It may be an int, a string/UUID, or a *UserContext, depending on what the
|
||||||
|
// RowSecurityProvider/SecurityContext.GetUserRef implementation returns.
|
||||||
|
UserID any `json:"user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Type) string {
|
func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Type) string {
|
||||||
@@ -42,7 +45,7 @@ func (m *RowSecurity) GetTemplate(pPrimaryKeyName string, pModelType reflect.Typ
|
|||||||
str = strings.ReplaceAll(str, "{PrimaryKeyName}", pPrimaryKeyName)
|
str = strings.ReplaceAll(str, "{PrimaryKeyName}", pPrimaryKeyName)
|
||||||
str = strings.ReplaceAll(str, "{TableName}", m.Tablename)
|
str = strings.ReplaceAll(str, "{TableName}", m.Tablename)
|
||||||
str = strings.ReplaceAll(str, "{SchemaName}", m.Schema)
|
str = strings.ReplaceAll(str, "{SchemaName}", m.Schema)
|
||||||
str = strings.ReplaceAll(str, "{UserID}", fmt.Sprintf("%d", m.UserID))
|
str = strings.ReplaceAll(str, "{UserID}", fmt.Sprintf("%v", m.UserID))
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +416,7 @@ func (m *SecurityList) ClearSecurity(pUserID int, pSchema, pTablename string) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema, pTablename string, pOverwrite bool) (RowSecurity, error) {
|
func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserRef any, pSchema, pTablename string, pOverwrite bool) (RowSecurity, error) {
|
||||||
if m.provider == nil {
|
if m.provider == nil {
|
||||||
return RowSecurity{}, fmt.Errorf("security provider not set")
|
return RowSecurity{}, fmt.Errorf("security provider not set")
|
||||||
}
|
}
|
||||||
@@ -424,10 +427,10 @@ func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema
|
|||||||
if m.RowSecurity == nil {
|
if m.RowSecurity == nil {
|
||||||
m.RowSecurity = make(map[string]RowSecurity, 0)
|
m.RowSecurity = make(map[string]RowSecurity, 0)
|
||||||
}
|
}
|
||||||
secKey := fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)
|
secKey := fmt.Sprintf("%s.%s@%v", pSchema, pTablename, pUserRef)
|
||||||
|
|
||||||
// Call the provider to load security rules
|
// Call the provider to load security rules
|
||||||
record, err := m.provider.GetRowSecurity(ctx, pUserID, pSchema, pTablename)
|
record, err := m.provider.GetRowSecurity(ctx, pUserRef, pSchema, pTablename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RowSecurity{}, fmt.Errorf("GetRowSecurity failed: %v", err)
|
return RowSecurity{}, fmt.Errorf("GetRowSecurity failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -436,7 +439,7 @@ func (m *SecurityList) LoadRowSecurity(ctx context.Context, pUserID int, pSchema
|
|||||||
return record, nil
|
return record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *SecurityList) GetRowSecurityTemplate(pUserID int, pSchema, pTablename string) (RowSecurity, error) {
|
func (m *SecurityList) GetRowSecurityTemplate(pUserRef any, pSchema, pTablename string) (RowSecurity, error) {
|
||||||
defer logger.CatchPanic("GetRowSecurityTemplate")()
|
defer logger.CatchPanic("GetRowSecurityTemplate")()
|
||||||
|
|
||||||
if m.RowSecurity == nil {
|
if m.RowSecurity == nil {
|
||||||
@@ -446,7 +449,7 @@ func (m *SecurityList) GetRowSecurityTemplate(pUserID int, pSchema, pTablename s
|
|||||||
m.RowSecurityMutex.RLock()
|
m.RowSecurityMutex.RLock()
|
||||||
defer m.RowSecurityMutex.RUnlock()
|
defer m.RowSecurityMutex.RUnlock()
|
||||||
|
|
||||||
rowSec, ok := m.RowSecurity[fmt.Sprintf("%s.%s@%d", pSchema, pTablename, pUserID)]
|
rowSec, ok := m.RowSecurity[fmt.Sprintf("%s.%s@%v", pSchema, pTablename, pUserRef)]
|
||||||
if !ok {
|
if !ok {
|
||||||
return RowSecurity{}, fmt.Errorf("no row security data")
|
return RowSecurity{}, fmt.Errorf("no row security data")
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-8
@@ -77,6 +77,9 @@ type DatabaseAuthenticator struct {
|
|||||||
cache *cache.Cache
|
cache *cache.Cache
|
||||||
cacheTTL time.Duration
|
cacheTTL time.Duration
|
||||||
sqlNames *SQLNames
|
sqlNames *SQLNames
|
||||||
|
tableNames *TableNames
|
||||||
|
queryMode QueryMode
|
||||||
|
capability *dbCapability
|
||||||
|
|
||||||
// Cookie session support (optional, gated by enableCookieSession)
|
// Cookie session support (optional, gated by enableCookieSession)
|
||||||
enableCookieSession bool
|
enableCookieSession bool
|
||||||
@@ -105,6 +108,10 @@ type DatabaseAuthenticatorOptions struct {
|
|||||||
// SQLNames provides custom SQL procedure/function names. If nil, uses DefaultSQLNames().
|
// SQLNames provides custom SQL procedure/function names. If nil, uses DefaultSQLNames().
|
||||||
// Partial overrides are supported: only set the fields you want to change.
|
// Partial overrides are supported: only set the fields you want to change.
|
||||||
SQLNames *SQLNames
|
SQLNames *SQLNames
|
||||||
|
// TableNames provides custom table names for Direct mode. If nil, uses DefaultTableNames().
|
||||||
|
TableNames *TableNames
|
||||||
|
// QueryMode selects stored-procedure vs Direct-mode SQL. Default (zero value) is ModeAuto.
|
||||||
|
QueryMode QueryMode
|
||||||
// DBFactory is called to obtain a fresh *sql.DB when the existing connection is closed.
|
// DBFactory is called to obtain a fresh *sql.DB when the existing connection is closed.
|
||||||
// If nil, reconnection is disabled.
|
// If nil, reconnection is disabled.
|
||||||
DBFactory func() (*sql.DB, error)
|
DBFactory func() (*sql.DB, error)
|
||||||
@@ -139,6 +146,7 @@ func NewDatabaseAuthenticatorWithOptions(db *sql.DB, opts DatabaseAuthenticatorO
|
|||||||
}
|
}
|
||||||
|
|
||||||
sqlNames := MergeSQLNames(DefaultSQLNames(), opts.SQLNames)
|
sqlNames := MergeSQLNames(DefaultSQLNames(), opts.SQLNames)
|
||||||
|
tableNames := resolveTableNames(opts.TableNames)
|
||||||
|
|
||||||
return &DatabaseAuthenticator{
|
return &DatabaseAuthenticator{
|
||||||
db: db,
|
db: db,
|
||||||
@@ -146,6 +154,9 @@ func NewDatabaseAuthenticatorWithOptions(db *sql.DB, opts DatabaseAuthenticatorO
|
|||||||
cache: cacheInstance,
|
cache: cacheInstance,
|
||||||
cacheTTL: opts.CacheTTL,
|
cacheTTL: opts.CacheTTL,
|
||||||
sqlNames: sqlNames,
|
sqlNames: sqlNames,
|
||||||
|
tableNames: tableNames,
|
||||||
|
queryMode: opts.QueryMode,
|
||||||
|
capability: newDBCapability(),
|
||||||
passkeyProvider: opts.PasskeyProvider,
|
passkeyProvider: opts.PasskeyProvider,
|
||||||
enableCookieSession: opts.EnableCookieSession,
|
enableCookieSession: opts.EnableCookieSession,
|
||||||
cookieOptions: opts.CookieOptions,
|
cookieOptions: opts.CookieOptions,
|
||||||
@@ -170,6 +181,9 @@ func (a *DatabaseAuthenticator) reconnectDB() error {
|
|||||||
a.dbMu.Lock()
|
a.dbMu.Lock()
|
||||||
a.db = newDB
|
a.db = newDB
|
||||||
a.dbMu.Unlock()
|
a.dbMu.Unlock()
|
||||||
|
if a.capability != nil {
|
||||||
|
a.capability.reset()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +208,9 @@ func (a *DatabaseAuthenticator) SetAuthenticateCallback(fn func(r *http.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *DatabaseAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
func (a *DatabaseAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.Login) {
|
||||||
|
return a.loginDirect(ctx, req)
|
||||||
|
}
|
||||||
// Convert LoginRequest to JSON
|
// Convert LoginRequest to JSON
|
||||||
reqJSON, err := json.Marshal(req)
|
reqJSON, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -230,6 +247,9 @@ func (a *DatabaseAuthenticator) Login(ctx context.Context, req LoginRequest) (*L
|
|||||||
|
|
||||||
// Register implements Registrable interface
|
// Register implements Registrable interface
|
||||||
func (a *DatabaseAuthenticator) Register(ctx context.Context, req RegisterRequest) (*LoginResponse, error) {
|
func (a *DatabaseAuthenticator) Register(ctx context.Context, req RegisterRequest) (*LoginResponse, error) {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.Register) {
|
||||||
|
return a.registerDirect(ctx, req)
|
||||||
|
}
|
||||||
// Convert RegisterRequest to JSON
|
// Convert RegisterRequest to JSON
|
||||||
reqJSON, err := json.Marshal(req)
|
reqJSON, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -265,6 +285,9 @@ func (a *DatabaseAuthenticator) Register(ctx context.Context, req RegisterReques
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *DatabaseAuthenticator) Logout(ctx context.Context, req LogoutRequest) error {
|
func (a *DatabaseAuthenticator) Logout(ctx context.Context, req LogoutRequest) error {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.Logout) {
|
||||||
|
return a.logoutDirect(ctx, req)
|
||||||
|
}
|
||||||
// Convert LogoutRequest to JSON
|
// Convert LogoutRequest to JSON
|
||||||
reqJSON, err := json.Marshal(req)
|
reqJSON, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -378,6 +401,10 @@ func (a *DatabaseAuthenticator) Authenticate(r *http.Request) (*UserContext, err
|
|||||||
var userCtx UserContext
|
var userCtx UserContext
|
||||||
err := a.cache.GetOrSet(r.Context(), cacheKey, &userCtx, a.cacheTTL, func() (any, error) {
|
err := a.cache.GetOrSet(r.Context(), cacheKey, &userCtx, a.cacheTTL, func() (any, error) {
|
||||||
// This function is called only if cache miss
|
// This function is called only if cache miss
|
||||||
|
if !a.capability.ShouldUseProcedure(r.Context(), a.queryMode, a.getDB(), a.sqlNames.Session) {
|
||||||
|
return a.sessionDirect(r.Context(), token)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
var userJSON sql.NullString
|
var userJSON sql.NullString
|
||||||
@@ -453,6 +480,11 @@ func (a *DatabaseAuthenticator) ClearUserCache(userID int) error {
|
|||||||
|
|
||||||
// updateSessionActivity updates the last activity timestamp for the session
|
// updateSessionActivity updates the last activity timestamp for the session
|
||||||
func (a *DatabaseAuthenticator) updateSessionActivity(ctx context.Context, sessionToken string, userCtx *UserContext) {
|
func (a *DatabaseAuthenticator) updateSessionActivity(ctx context.Context, sessionToken string, userCtx *UserContext) {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.SessionUpdate) {
|
||||||
|
_ = a.updateSessionActivityDirect(ctx, sessionToken)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Convert UserContext to JSON
|
// Convert UserContext to JSON
|
||||||
userJSON, err := json.Marshal(userCtx)
|
userJSON, err := json.Marshal(userCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -471,6 +503,9 @@ func (a *DatabaseAuthenticator) updateSessionActivity(ctx context.Context, sessi
|
|||||||
|
|
||||||
// RefreshToken implements Refreshable interface
|
// RefreshToken implements Refreshable interface
|
||||||
func (a *DatabaseAuthenticator) RefreshToken(ctx context.Context, refreshToken string) (*LoginResponse, error) {
|
func (a *DatabaseAuthenticator) RefreshToken(ctx context.Context, refreshToken string) (*LoginResponse, error) {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.RefreshToken) {
|
||||||
|
return a.refreshTokenDirect(ctx, refreshToken)
|
||||||
|
}
|
||||||
// First, we need to get the current user context for the refresh token
|
// First, we need to get the current user context for the refresh token
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
@@ -533,6 +568,9 @@ type JWTAuthenticator struct {
|
|||||||
dbMu sync.RWMutex
|
dbMu sync.RWMutex
|
||||||
dbFactory func() (*sql.DB, error)
|
dbFactory func() (*sql.DB, error)
|
||||||
sqlNames *SQLNames
|
sqlNames *SQLNames
|
||||||
|
tableNames *TableNames
|
||||||
|
queryMode QueryMode
|
||||||
|
capability *dbCapability
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewJWTAuthenticator(secretKey string, db *sql.DB, names ...*SQLNames) *JWTAuthenticator {
|
func NewJWTAuthenticator(secretKey string, db *sql.DB, names ...*SQLNames) *JWTAuthenticator {
|
||||||
@@ -540,6 +578,8 @@ func NewJWTAuthenticator(secretKey string, db *sql.DB, names ...*SQLNames) *JWTA
|
|||||||
secretKey: []byte(secretKey),
|
secretKey: []byte(secretKey),
|
||||||
db: db,
|
db: db,
|
||||||
sqlNames: resolveSQLNames(names...),
|
sqlNames: resolveSQLNames(names...),
|
||||||
|
tableNames: DefaultTableNames(),
|
||||||
|
capability: newDBCapability(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,6 +589,18 @@ func (a *JWTAuthenticator) WithDBFactory(factory func() (*sql.DB, error)) *JWTAu
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithTableNames configures Direct-mode table names. If names is nil, defaults are used.
|
||||||
|
func (a *JWTAuthenticator) WithTableNames(names *TableNames) *JWTAuthenticator {
|
||||||
|
a.tableNames = resolveTableNames(names)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithQueryMode selects stored-procedure vs Direct-mode SQL (default ModeAuto).
|
||||||
|
func (a *JWTAuthenticator) WithQueryMode(mode QueryMode) *JWTAuthenticator {
|
||||||
|
a.queryMode = mode
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
func (a *JWTAuthenticator) getDB() *sql.DB {
|
func (a *JWTAuthenticator) getDB() *sql.DB {
|
||||||
a.dbMu.RLock()
|
a.dbMu.RLock()
|
||||||
defer a.dbMu.RUnlock()
|
defer a.dbMu.RUnlock()
|
||||||
@@ -566,10 +618,17 @@ func (a *JWTAuthenticator) reconnectDB() error {
|
|||||||
a.dbMu.Lock()
|
a.dbMu.Lock()
|
||||||
a.db = newDB
|
a.db = newDB
|
||||||
a.dbMu.Unlock()
|
a.dbMu.Unlock()
|
||||||
|
if a.capability != nil {
|
||||||
|
a.capability.reset()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *JWTAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
func (a *JWTAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.JWTLogin) {
|
||||||
|
return a.jwtLoginDirect(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
var userJSON []byte
|
var userJSON []byte
|
||||||
@@ -632,6 +691,10 @@ func (a *JWTAuthenticator) Login(ctx context.Context, req LoginRequest) (*LoginR
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *JWTAuthenticator) Logout(ctx context.Context, req LogoutRequest) error {
|
func (a *JWTAuthenticator) Logout(ctx context.Context, req LogoutRequest) error {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.JWTLogout) {
|
||||||
|
return a.jwtLogoutDirect(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
|
|
||||||
@@ -685,10 +748,19 @@ type DatabaseColumnSecurityProvider struct {
|
|||||||
dbMu sync.RWMutex
|
dbMu sync.RWMutex
|
||||||
dbFactory func() (*sql.DB, error)
|
dbFactory func() (*sql.DB, error)
|
||||||
sqlNames *SQLNames
|
sqlNames *SQLNames
|
||||||
|
queryMode QueryMode
|
||||||
|
capability *dbCapability
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDatabaseColumnSecurityProvider(db *sql.DB, names ...*SQLNames) *DatabaseColumnSecurityProvider {
|
func NewDatabaseColumnSecurityProvider(db *sql.DB, names ...*SQLNames) *DatabaseColumnSecurityProvider {
|
||||||
return &DatabaseColumnSecurityProvider{db: db, sqlNames: resolveSQLNames(names...)}
|
return &DatabaseColumnSecurityProvider{db: db, sqlNames: resolveSQLNames(names...), capability: newDBCapability()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithQueryMode selects stored-procedure vs Direct-mode SQL (default ModeAuto).
|
||||||
|
// Direct mode is unsupported for column security (see ErrDirectModeUnsupported).
|
||||||
|
func (p *DatabaseColumnSecurityProvider) WithQueryMode(mode QueryMode) *DatabaseColumnSecurityProvider {
|
||||||
|
p.queryMode = mode
|
||||||
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *DatabaseColumnSecurityProvider) WithDBFactory(factory func() (*sql.DB, error)) *DatabaseColumnSecurityProvider {
|
func (p *DatabaseColumnSecurityProvider) WithDBFactory(factory func() (*sql.DB, error)) *DatabaseColumnSecurityProvider {
|
||||||
@@ -713,10 +785,17 @@ func (p *DatabaseColumnSecurityProvider) reconnectDB() error {
|
|||||||
p.dbMu.Lock()
|
p.dbMu.Lock()
|
||||||
p.db = newDB
|
p.db = newDB
|
||||||
p.dbMu.Unlock()
|
p.dbMu.Unlock()
|
||||||
|
if p.capability != nil {
|
||||||
|
p.capability.reset()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *DatabaseColumnSecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]ColumnSecurity, error) {
|
func (p *DatabaseColumnSecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]ColumnSecurity, error) {
|
||||||
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.ColumnSecurity) {
|
||||||
|
return nil, ErrDirectModeUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
var rules []ColumnSecurity
|
var rules []ColumnSecurity
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
@@ -785,10 +864,19 @@ type DatabaseRowSecurityProvider struct {
|
|||||||
dbMu sync.RWMutex
|
dbMu sync.RWMutex
|
||||||
dbFactory func() (*sql.DB, error)
|
dbFactory func() (*sql.DB, error)
|
||||||
sqlNames *SQLNames
|
sqlNames *SQLNames
|
||||||
|
queryMode QueryMode
|
||||||
|
capability *dbCapability
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDatabaseRowSecurityProvider(db *sql.DB, names ...*SQLNames) *DatabaseRowSecurityProvider {
|
func NewDatabaseRowSecurityProvider(db *sql.DB, names ...*SQLNames) *DatabaseRowSecurityProvider {
|
||||||
return &DatabaseRowSecurityProvider{db: db, sqlNames: resolveSQLNames(names...)}
|
return &DatabaseRowSecurityProvider{db: db, sqlNames: resolveSQLNames(names...), capability: newDBCapability()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithQueryMode selects stored-procedure vs Direct-mode SQL (default ModeAuto).
|
||||||
|
// Direct mode is unsupported for row security (see ErrDirectModeUnsupported).
|
||||||
|
func (p *DatabaseRowSecurityProvider) WithQueryMode(mode QueryMode) *DatabaseRowSecurityProvider {
|
||||||
|
p.queryMode = mode
|
||||||
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *DatabaseRowSecurityProvider) WithDBFactory(factory func() (*sql.DB, error)) *DatabaseRowSecurityProvider {
|
func (p *DatabaseRowSecurityProvider) WithDBFactory(factory func() (*sql.DB, error)) *DatabaseRowSecurityProvider {
|
||||||
@@ -813,16 +901,23 @@ func (p *DatabaseRowSecurityProvider) reconnectDB() error {
|
|||||||
p.dbMu.Lock()
|
p.dbMu.Lock()
|
||||||
p.db = newDB
|
p.db = newDB
|
||||||
p.dbMu.Unlock()
|
p.dbMu.Unlock()
|
||||||
|
if p.capability != nil {
|
||||||
|
p.capability.reset()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
|
func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
|
||||||
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.RowSecurity) {
|
||||||
|
return RowSecurity{}, ErrDirectModeUnsupported
|
||||||
|
}
|
||||||
|
|
||||||
var template string
|
var template string
|
||||||
var hasBlock bool
|
var hasBlock bool
|
||||||
|
|
||||||
runQuery := func() error {
|
runQuery := func() error {
|
||||||
query := fmt.Sprintf(`SELECT p_template, p_block FROM %s($1, $2, $3)`, p.sqlNames.RowSecurity)
|
query := fmt.Sprintf(`SELECT p_template, p_block FROM %s($1, $2, $3)`, p.sqlNames.RowSecurity)
|
||||||
return p.getDB().QueryRowContext(ctx, query, schema, table, userID).Scan(&template, &hasBlock)
|
return p.getDB().QueryRowContext(ctx, query, schema, table, userRef).Scan(&template, &hasBlock)
|
||||||
}
|
}
|
||||||
err := runQuery()
|
err := runQuery()
|
||||||
if isDBClosed(err) {
|
if isDBClosed(err) {
|
||||||
@@ -837,7 +932,7 @@ func (p *DatabaseRowSecurityProvider) GetRowSecurity(ctx context.Context, userID
|
|||||||
return RowSecurity{
|
return RowSecurity{
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Tablename: table,
|
Tablename: table,
|
||||||
UserID: userID,
|
UserID: userRef,
|
||||||
Template: template,
|
Template: template,
|
||||||
HasBlock: hasBlock,
|
HasBlock: hasBlock,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -874,14 +969,14 @@ func NewConfigRowSecurityProvider(templates map[string]string, blocked map[strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (RowSecurity, error) {
|
func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userRef any, schema, table string) (RowSecurity, error) {
|
||||||
key := fmt.Sprintf("%s.%s", schema, table)
|
key := fmt.Sprintf("%s.%s", schema, table)
|
||||||
|
|
||||||
if p.blocked[key] {
|
if p.blocked[key] {
|
||||||
return RowSecurity{
|
return RowSecurity{
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Tablename: table,
|
Tablename: table,
|
||||||
UserID: userID,
|
UserID: userRef,
|
||||||
HasBlock: true,
|
HasBlock: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -890,7 +985,7 @@ func (p *ConfigRowSecurityProvider) GetRowSecurity(ctx context.Context, userID i
|
|||||||
return RowSecurity{
|
return RowSecurity{
|
||||||
Schema: schema,
|
Schema: schema,
|
||||||
Tablename: table,
|
Tablename: table,
|
||||||
UserID: userID,
|
UserID: userRef,
|
||||||
Template: template,
|
Template: template,
|
||||||
HasBlock: false,
|
HasBlock: false,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -950,6 +1045,9 @@ func generateRandomString(length int) string {
|
|||||||
// RequestPasswordReset implements PasswordResettable. It calls the stored procedure
|
// RequestPasswordReset implements PasswordResettable. It calls the stored procedure
|
||||||
// resolvespec_password_reset_request and returns the reset token and expiry.
|
// resolvespec_password_reset_request and returns the reset token and expiry.
|
||||||
func (a *DatabaseAuthenticator) RequestPasswordReset(ctx context.Context, req PasswordResetRequest) (*PasswordResetResponse, error) {
|
func (a *DatabaseAuthenticator) RequestPasswordReset(ctx context.Context, req PasswordResetRequest) (*PasswordResetResponse, error) {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.PasswordResetRequest) {
|
||||||
|
return a.requestPasswordResetDirect(ctx, req)
|
||||||
|
}
|
||||||
reqJSON, err := json.Marshal(req)
|
reqJSON, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to marshal password reset request: %w", err)
|
return nil, fmt.Errorf("failed to marshal password reset request: %w", err)
|
||||||
@@ -987,6 +1085,9 @@ func (a *DatabaseAuthenticator) RequestPasswordReset(ctx context.Context, req Pa
|
|||||||
// CompletePasswordReset implements PasswordResettable. It validates the token and
|
// CompletePasswordReset implements PasswordResettable. It validates the token and
|
||||||
// updates the user's password via resolvespec_password_reset.
|
// updates the user's password via resolvespec_password_reset.
|
||||||
func (a *DatabaseAuthenticator) CompletePasswordReset(ctx context.Context, req PasswordResetCompleteRequest) error {
|
func (a *DatabaseAuthenticator) CompletePasswordReset(ctx context.Context, req PasswordResetCompleteRequest) error {
|
||||||
|
if !a.capability.ShouldUseProcedure(ctx, a.queryMode, a.getDB(), a.sqlNames.PasswordResetComplete) {
|
||||||
|
return a.completePasswordResetDirect(ctx, req)
|
||||||
|
}
|
||||||
reqJSON, err := json.Marshal(req)
|
reqJSON, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal password reset complete request: %w", err)
|
return fmt.Errorf("failed to marshal password reset complete request: %w", err)
|
||||||
|
|||||||
+473
@@ -0,0 +1,473 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Direct-mode implementations for DatabaseAuthenticator and JWTAuthenticator.
|
||||||
|
// These mirror the plpgsql bodies in database_schema.sql using plain
|
||||||
|
// parameterized SQL against the configured TableNames, so they work on
|
||||||
|
// SQLite, MySQL, or Postgres without the resolvespec_* functions installed.
|
||||||
|
//
|
||||||
|
// Password verification is intentionally not implemented here: the stored
|
||||||
|
// procedures never verify the password hash either (see the TODOs in
|
||||||
|
// database_schema.sql), so Direct mode matches that behavior exactly rather
|
||||||
|
// than introducing a mismatch between modes.
|
||||||
|
|
||||||
|
var (
|
||||||
|
errUsernameExists = errors.New("username already exists")
|
||||||
|
errEmailExists = errors.New("email already exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *DatabaseAuthenticator) loginDirect(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||||
|
var userID int
|
||||||
|
var email, roles, programUserTable sql.NullString
|
||||||
|
var userLevel, programUserID sql.NullInt64
|
||||||
|
|
||||||
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`SELECT id, email, user_level, roles, program_user_id, program_user_table FROM %s WHERE username = ? AND is_active = ?`,
|
||||||
|
a.tableNames.Users))
|
||||||
|
return db.QueryRowContext(ctx, query, req.Username, true).Scan(&userID, &email, &userLevel, &roles, &programUserID, &programUserTable)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("login query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionToken, err := generateSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate session token: %w", err)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
expiresAt := now.Add(24 * time.Hour)
|
||||||
|
ipAddress, userAgent := claimStrings(req.Claims)
|
||||||
|
|
||||||
|
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
insertQuery := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`INSERT INTO %s (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
a.tableNames.UserSessions))
|
||||||
|
if _, err := db.ExecContext(ctx, insertQuery, sessionToken, userID, expiresAt, ipAddress, userAgent, now, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updateQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET last_login_at = ? WHERE id = ?`, a.tableNames.Users))
|
||||||
|
_, err := db.ExecContext(ctx, updateQuery, now, userID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("login query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userCtx := &UserContext{
|
||||||
|
UserID: userID,
|
||||||
|
UserName: req.Username,
|
||||||
|
Email: email.String,
|
||||||
|
UserLevel: int(userLevel.Int64),
|
||||||
|
Roles: parseRoles(roles.String),
|
||||||
|
SessionID: sessionToken,
|
||||||
|
ProgramUserID: int(programUserID.Int64),
|
||||||
|
ProgramUserTable: programUserTable.String,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LoginResponse{
|
||||||
|
Token: sessionToken,
|
||||||
|
User: userCtx,
|
||||||
|
ExpiresIn: 86400,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DatabaseAuthenticator) registerDirect(ctx context.Context, req RegisterRequest) (*LoginResponse, error) {
|
||||||
|
if req.Username == "" {
|
||||||
|
return nil, fmt.Errorf("username is required")
|
||||||
|
}
|
||||||
|
if req.Email == "" {
|
||||||
|
return nil, fmt.Errorf("email is required")
|
||||||
|
}
|
||||||
|
if req.Password == "" {
|
||||||
|
return nil, fmt.Errorf("password is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
rolesStr := strings.Join(req.Roles, ",")
|
||||||
|
now := time.Now()
|
||||||
|
ipAddress, userAgent := claimStrings(req.Claims)
|
||||||
|
|
||||||
|
var userID int64
|
||||||
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
var count int
|
||||||
|
checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE username = ?`, a.tableNames.Users))
|
||||||
|
if err := db.QueryRowContext(ctx, checkQuery, req.Username).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return errUsernameExists
|
||||||
|
}
|
||||||
|
checkQuery2 := rewritePlaceholders(db, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE email = ?`, a.tableNames.Users))
|
||||||
|
if err := db.QueryRowContext(ctx, checkQuery2, req.Email).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return errEmailExists
|
||||||
|
}
|
||||||
|
|
||||||
|
insertQuery := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`INSERT INTO %s (username, email, password, user_level, roles, is_active, created_at, updated_at, program_user_id, program_user_table) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
a.tableNames.Users))
|
||||||
|
res, err := db.ExecContext(ctx, insertQuery, req.Username, req.Email, req.Password, req.UserLevel, rolesStr, true, now, now, 0, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userID, err = res.LastInsertId()
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errUsernameExists) {
|
||||||
|
return nil, errUsernameExists
|
||||||
|
}
|
||||||
|
if errors.Is(err, errEmailExists) {
|
||||||
|
return nil, errEmailExists
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("register query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionToken, err := generateSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate session token: %w", err)
|
||||||
|
}
|
||||||
|
expiresAt := now.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
insertSession := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`INSERT INTO %s (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
a.tableNames.UserSessions))
|
||||||
|
if _, err := db.ExecContext(ctx, insertSession, sessionToken, userID, expiresAt, ipAddress, userAgent, now, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updUser := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET last_login_at = ? WHERE id = ?`, a.tableNames.Users))
|
||||||
|
_, err := db.ExecContext(ctx, updUser, now, userID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("register query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userCtx := &UserContext{
|
||||||
|
UserID: int(userID),
|
||||||
|
UserName: req.Username,
|
||||||
|
Email: req.Email,
|
||||||
|
UserLevel: req.UserLevel,
|
||||||
|
Roles: parseRoles(rolesStr),
|
||||||
|
SessionID: sessionToken,
|
||||||
|
ProgramUserID: 0,
|
||||||
|
ProgramUserTable: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LoginResponse{
|
||||||
|
Token: sessionToken,
|
||||||
|
User: userCtx,
|
||||||
|
ExpiresIn: 86400,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DatabaseAuthenticator) logoutDirect(ctx context.Context, req LogoutRequest) error {
|
||||||
|
token := req.Token
|
||||||
|
token = strings.TrimPrefix(token, "Bearer ")
|
||||||
|
token = strings.TrimPrefix(token, "bearer ")
|
||||||
|
|
||||||
|
var rows int64
|
||||||
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE session_token = ? AND user_id = ?`, a.tableNames.UserSessions))
|
||||||
|
res, err := db.ExecContext(ctx, query, token, req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err = res.RowsAffected()
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("logout query failed: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("session not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Token != "" {
|
||||||
|
cacheKey := fmt.Sprintf("auth:session:%s", req.Token)
|
||||||
|
_ = a.cache.Delete(ctx, cacheKey)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DatabaseAuthenticator) sessionDirect(ctx context.Context, token string) (*UserContext, error) {
|
||||||
|
var userID int
|
||||||
|
var username, email, roles, programUserTable sql.NullString
|
||||||
|
var userLevel, programUserID sql.NullInt64
|
||||||
|
|
||||||
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`SELECT s.user_id, u.username, u.email, u.user_level, u.roles, u.program_user_id, u.program_user_table
|
||||||
|
FROM %s s JOIN %s u ON s.user_id = u.id
|
||||||
|
WHERE s.session_token = ? AND s.expires_at > ? AND u.is_active = ?`,
|
||||||
|
a.tableNames.UserSessions, a.tableNames.Users))
|
||||||
|
return db.QueryRowContext(ctx, query, token, time.Now(), true).Scan(&userID, &username, &email, &userLevel, &roles, &programUserID, &programUserTable)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("invalid or expired session")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("session query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UserContext{
|
||||||
|
UserID: userID,
|
||||||
|
UserName: username.String,
|
||||||
|
Email: email.String,
|
||||||
|
UserLevel: int(userLevel.Int64),
|
||||||
|
SessionID: token,
|
||||||
|
Roles: parseRoles(roles.String),
|
||||||
|
ProgramUserID: int(programUserID.Int64),
|
||||||
|
ProgramUserTable: programUserTable.String,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DatabaseAuthenticator) updateSessionActivityDirect(ctx context.Context, sessionToken string) error {
|
||||||
|
return a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET last_activity_at = ? WHERE session_token = ? AND expires_at > ?`, a.tableNames.UserSessions))
|
||||||
|
_, err := db.ExecContext(ctx, query, time.Now(), sessionToken, time.Now())
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DatabaseAuthenticator) refreshTokenDirect(ctx context.Context, oldToken string) (*LoginResponse, error) {
|
||||||
|
var userID int
|
||||||
|
var username, email, roles, ipAddress, userAgent, programUserTable sql.NullString
|
||||||
|
var userLevel, programUserID sql.NullInt64
|
||||||
|
|
||||||
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`SELECT s.user_id, u.username, u.email, u.user_level, u.roles, s.ip_address, s.user_agent, u.program_user_id, u.program_user_table
|
||||||
|
FROM %s s JOIN %s u ON s.user_id = u.id
|
||||||
|
WHERE s.session_token = ? AND s.expires_at > ? AND u.is_active = ?`,
|
||||||
|
a.tableNames.UserSessions, a.tableNames.Users))
|
||||||
|
return db.QueryRowContext(ctx, query, oldToken, time.Now(), true).Scan(&userID, &username, &email, &userLevel, &roles, &ipAddress, &userAgent, &programUserID, &programUserTable)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("invalid or expired refresh token")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("refresh token query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newToken, err := generateSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate session token: %w", err)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
expiresAt := now.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
insertQuery := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`INSERT INTO %s (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
a.tableNames.UserSessions))
|
||||||
|
if _, err := db.ExecContext(ctx, insertQuery, newToken, userID, expiresAt, ipAddress.String, userAgent.String, now, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE session_token = ?`, a.tableNames.UserSessions))
|
||||||
|
_, err := db.ExecContext(ctx, delQuery, oldToken)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("refresh token generation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userCtx := &UserContext{
|
||||||
|
UserID: userID,
|
||||||
|
UserName: username.String,
|
||||||
|
Email: email.String,
|
||||||
|
UserLevel: int(userLevel.Int64),
|
||||||
|
SessionID: newToken,
|
||||||
|
Roles: parseRoles(roles.String),
|
||||||
|
ProgramUserID: int(programUserID.Int64),
|
||||||
|
ProgramUserTable: programUserTable.String,
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LoginResponse{
|
||||||
|
Token: newToken,
|
||||||
|
User: userCtx,
|
||||||
|
ExpiresIn: int64(24 * time.Hour.Seconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DatabaseAuthenticator) requestPasswordResetDirect(ctx context.Context, req PasswordResetRequest) (*PasswordResetResponse, error) {
|
||||||
|
if req.Email == "" && req.Username == "" {
|
||||||
|
return nil, fmt.Errorf("email or username is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID int
|
||||||
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
var query string
|
||||||
|
var arg string
|
||||||
|
if req.Email != "" {
|
||||||
|
query = rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE email = ? AND is_active = ?`, a.tableNames.Users))
|
||||||
|
arg = req.Email
|
||||||
|
} else {
|
||||||
|
query = rewritePlaceholders(db, fmt.Sprintf(`SELECT id FROM %s WHERE username = ? AND is_active = ?`, a.tableNames.Users))
|
||||||
|
arg = req.Username
|
||||||
|
}
|
||||||
|
return db.QueryRowContext(ctx, query, arg, true).Scan(&userID)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// Return generic success even when user not found to avoid user enumeration.
|
||||||
|
return &PasswordResetResponse{Token: "", ExpiresIn: 0}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("password reset request query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawBytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(rawBytes); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate reset token: %w", err)
|
||||||
|
}
|
||||||
|
rawToken := hex.EncodeToString(rawBytes)
|
||||||
|
hash := sha256.Sum256([]byte(rawToken))
|
||||||
|
tokenHash := hex.EncodeToString(hash[:])
|
||||||
|
now := time.Now()
|
||||||
|
expiresAt := now.Add(1 * time.Hour)
|
||||||
|
|
||||||
|
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ? AND used = ?`, a.tableNames.UserPasswordResets))
|
||||||
|
if _, err := db.ExecContext(ctx, delQuery, userID, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
insQuery := rewritePlaceholders(db, fmt.Sprintf(`INSERT INTO %s (user_id, token_hash, expires_at, created_at, used) VALUES (?, ?, ?, ?, ?)`, a.tableNames.UserPasswordResets))
|
||||||
|
_, err := db.ExecContext(ctx, insQuery, userID, tokenHash, expiresAt, now, false)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("password reset request query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PasswordResetResponse{Token: rawToken, ExpiresIn: 3600}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *DatabaseAuthenticator) completePasswordResetDirect(ctx context.Context, req PasswordResetCompleteRequest) error {
|
||||||
|
if req.Token == "" {
|
||||||
|
return fmt.Errorf("token is required")
|
||||||
|
}
|
||||||
|
if req.NewPassword == "" {
|
||||||
|
return fmt.Errorf("new_password is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := sha256.Sum256([]byte(req.Token))
|
||||||
|
tokenHash := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
var resetID, userID int
|
||||||
|
var expiresAt time.Time
|
||||||
|
err := a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT id, user_id, expires_at FROM %s WHERE token_hash = ? AND used = ?`, a.tableNames.UserPasswordResets))
|
||||||
|
return db.QueryRowContext(ctx, query, tokenHash, false).Scan(&resetID, &userID, &expiresAt)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return fmt.Errorf("invalid or expired token")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("password reset complete query failed: %w", err)
|
||||||
|
}
|
||||||
|
if !expiresAt.After(time.Now()) {
|
||||||
|
return fmt.Errorf("invalid or expired token")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
err = a.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
updUser := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET password = ?, updated_at = ? WHERE id = ?`, a.tableNames.Users))
|
||||||
|
if _, err := db.ExecContext(ctx, updUser, req.NewPassword, now, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delSessions := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, a.tableNames.UserSessions))
|
||||||
|
if _, err := db.ExecContext(ctx, delSessions, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updReset := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET used = ?, used_at = ? WHERE id = ?`, a.tableNames.UserPasswordResets))
|
||||||
|
_, err := db.ExecContext(ctx, updReset, true, now, resetID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("password reset complete query failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimStrings extracts ip_address/user_agent from a request's Claims map, mirroring
|
||||||
|
// p_request->'claims'->>'ip_address' / 'user_agent' in the plpgsql procedures.
|
||||||
|
func claimStrings(claims map[string]any) (ipAddress, userAgent string) {
|
||||||
|
if claims == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
if v, ok := claims["ip_address"].(string); ok {
|
||||||
|
ipAddress = v
|
||||||
|
}
|
||||||
|
if v, ok := claims["user_agent"].(string); ok {
|
||||||
|
userAgent = v
|
||||||
|
}
|
||||||
|
return ipAddress, userAgent
|
||||||
|
}
|
||||||
|
|
||||||
|
// jwtLoginDirect mirrors resolvespec_jwt_login.
|
||||||
|
func (a *JWTAuthenticator) jwtLoginDirect(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||||
|
var userID int
|
||||||
|
var email, roles sql.NullString
|
||||||
|
var userLevel sql.NullInt64
|
||||||
|
|
||||||
|
runQuery := func() error {
|
||||||
|
query := rewritePlaceholders(a.getDB(), fmt.Sprintf(`SELECT id, email, user_level, roles FROM %s WHERE username = ? AND is_active = ?`, a.tableNames.Users))
|
||||||
|
return a.getDB().QueryRowContext(ctx, query, req.Username, true).Scan(&userID, &email, &userLevel, &roles)
|
||||||
|
}
|
||||||
|
err := runQuery()
|
||||||
|
if isDBClosed(err) {
|
||||||
|
if reconnErr := a.reconnectDB(); reconnErr == nil {
|
||||||
|
err = runQuery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("login query failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(24 * time.Hour)
|
||||||
|
tokenString := fmt.Sprintf("token_%d_%d", userID, expiresAt.Unix())
|
||||||
|
|
||||||
|
return &LoginResponse{
|
||||||
|
Token: tokenString,
|
||||||
|
User: &UserContext{
|
||||||
|
UserID: userID,
|
||||||
|
UserName: req.Username,
|
||||||
|
Email: email.String,
|
||||||
|
UserLevel: int(userLevel.Int64),
|
||||||
|
Roles: parseRoles(roles.String),
|
||||||
|
},
|
||||||
|
ExpiresIn: int64(24 * time.Hour.Seconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jwtLogoutDirect mirrors resolvespec_jwt_logout (adds token to the blacklist table).
|
||||||
|
func (a *JWTAuthenticator) jwtLogoutDirect(ctx context.Context, req LogoutRequest) error {
|
||||||
|
db := a.getDB()
|
||||||
|
expiresAt := time.Now().Add(24 * time.Hour)
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(`INSERT INTO %s (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)`, a.tableNames.TokenBlacklist))
|
||||||
|
_, err := db.ExecContext(ctx, query, req.Token, req.UserID, expiresAt, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("logout query failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QueryMode selects how a provider talks to the database: via the configured
|
||||||
|
// resolvespec_* stored procedure (ModeProcedure), via portable Go/SQL logic
|
||||||
|
// (ModeDirect), or auto-detected per connection (ModeAuto, the default).
|
||||||
|
type QueryMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ModeAuto probes whether the configured stored procedure exists on a
|
||||||
|
// Postgres connection and uses it if so, otherwise falls back to Direct mode.
|
||||||
|
ModeAuto QueryMode = iota
|
||||||
|
// ModeProcedure always calls the configured resolvespec_* stored procedure.
|
||||||
|
ModeProcedure
|
||||||
|
// ModeDirect always uses the portable Go/SQL implementation, never the
|
||||||
|
// stored procedure.
|
||||||
|
ModeDirect
|
||||||
|
)
|
||||||
|
|
||||||
|
// dbCapability probes and caches whether a given *sql.DB is Postgres and
|
||||||
|
// whether specific stored procedures exist on it. One instance is shared by
|
||||||
|
// a provider (DatabaseAuthenticator, DatabaseTwoFactorProvider, etc.) across
|
||||||
|
// all of its operations.
|
||||||
|
type dbCapability struct {
|
||||||
|
funcExists sync.Map // procName (string) -> exists (bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDBCapability creates a new, empty capability cache.
|
||||||
|
func newDBCapability() *dbCapability {
|
||||||
|
return &dbCapability{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset clears all cached probe results. Call after reconnecting to a
|
||||||
|
// (possibly different) database.
|
||||||
|
func (c *dbCapability) reset() {
|
||||||
|
c.funcExists.Range(func(key, _ any) bool {
|
||||||
|
c.funcExists.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeFunctionExists checks, via a Postgres-specific system catalog query,
|
||||||
|
// whether a function named procName exists. Any error (wrong dialect,
|
||||||
|
// placeholder syntax rejected, relation missing, etc.) is treated as "does
|
||||||
|
// not exist" rather than propagated - the probe must never be able to panic
|
||||||
|
// or block resolution of the query mode.
|
||||||
|
func probeFunctionExists(ctx context.Context, db *sql.DB, procName string) bool {
|
||||||
|
if db == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var exists bool
|
||||||
|
defer func() {
|
||||||
|
// Guard against any unexpected panic from a misbehaving driver.
|
||||||
|
_ = recover()
|
||||||
|
}()
|
||||||
|
row := db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM pg_proc WHERE proname = $1 LIMIT 1)`, procName)
|
||||||
|
if err := row.Scan(&exists); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldUseProcedure decides whether the stored-procedure code path should be
|
||||||
|
// used for procName given mode. ModeProcedure/ModeDirect are unconditional.
|
||||||
|
//
|
||||||
|
// ModeAuto resolves as follows:
|
||||||
|
// - A recognized portable-only driver (SQLite, MySQL) always uses Direct
|
||||||
|
// mode; no query is issued.
|
||||||
|
// - A recognized Postgres driver (lib/pq, pgx) probes pg_proc for procName
|
||||||
|
// and uses the stored procedure only if it actually exists there.
|
||||||
|
// - Any other/unrecognized driver (including test doubles such as
|
||||||
|
// sqlmock) cannot be safely dialect-probed without risking an
|
||||||
|
// unexpected query against a strictly-ordered mock, so it defaults to
|
||||||
|
// the stored-procedure path, preserving pre-existing behavior for
|
||||||
|
// callers that configure Postgres-flavored access without an
|
||||||
|
// identifiable driver type.
|
||||||
|
func (c *dbCapability) ShouldUseProcedure(ctx context.Context, mode QueryMode, db *sql.DB, procName string) bool {
|
||||||
|
switch mode {
|
||||||
|
case ModeProcedure:
|
||||||
|
return true
|
||||||
|
case ModeDirect:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
if cached, ok := c.funcExists.Load(procName); ok {
|
||||||
|
return cached.(bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
switch {
|
||||||
|
case driverIsPortableOnly(db):
|
||||||
|
exists = false
|
||||||
|
case driverIsPostgres(db):
|
||||||
|
exists = probeFunctionExists(ctx, db, procName)
|
||||||
|
default:
|
||||||
|
exists = true
|
||||||
|
}
|
||||||
|
|
||||||
|
c.funcExists.Store(procName, exists)
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// driverIsPostgres reports whether db's underlying driver looks like a
|
||||||
|
// Postgres driver (lib/pq or pgx), based on the driver's Go type name.
|
||||||
|
func driverIsPostgres(db *sql.DB) bool {
|
||||||
|
if db == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t := strings.ToLower(fmt.Sprintf("%T", db.Driver()))
|
||||||
|
return strings.Contains(t, "pq.") || strings.Contains(t, "pgx") || strings.Contains(t, "postgres")
|
||||||
|
}
|
||||||
|
|
||||||
|
// driverIsPortableOnly reports whether db's underlying driver is a dialect
|
||||||
|
// that never has the resolvespec_* Postgres functions available (SQLite,
|
||||||
|
// MySQL), so ModeAuto can skip probing entirely and go straight to Direct.
|
||||||
|
func driverIsPortableOnly(db *sql.DB) bool {
|
||||||
|
if db == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t := strings.ToLower(fmt.Sprintf("%T", db.Driver()))
|
||||||
|
return strings.Contains(t, "sqlite") || strings.Contains(t, "mysql")
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewritePlaceholders converts a query written with "?" placeholders
|
||||||
|
// (SQLite/MySQL style) to Postgres "$1", "$2", ... style when db's driver is
|
||||||
|
// Postgres. All Direct-mode SQL in this package is written with "?" and
|
||||||
|
// passed through this helper before execution so the same query source works
|
||||||
|
// against SQLite, MySQL, and (in the rare fallback case) Postgres without the
|
||||||
|
// resolvespec_* functions installed.
|
||||||
|
func rewritePlaceholders(db *sql.DB, query string) string {
|
||||||
|
if !driverIsPostgres(db) {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
n := 0
|
||||||
|
for _, r := range query {
|
||||||
|
if r == '?' {
|
||||||
|
n++
|
||||||
|
b.WriteString("$")
|
||||||
|
b.WriteString(strconv.Itoa(n))
|
||||||
|
} else {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSessionToken produces a session token in the same shape the
|
||||||
|
// plpgsql stored procedures generate: "sess_" + hex(32 random bytes) + "_" +
|
||||||
|
// unix timestamp, so downstream code that parses/displays tokens is
|
||||||
|
// unaffected by which mode created them.
|
||||||
|
func generateSessionToken() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("sess_%s_%d", hex.EncodeToString(buf), time.Now().Unix()), nil
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrDirectModeUnsupported is returned by Direct-mode operations that have no
|
||||||
|
// portable equivalent because they depend on an external schema this package
|
||||||
|
// does not own (e.g. core.secaccess / core.hub_link for column/row security).
|
||||||
|
// Callers needing that functionality must run against Postgres with the
|
||||||
|
// resolvespec_column_security / resolvespec_row_security stored procedures
|
||||||
|
// installed (ModeProcedure or ModeAuto with the procedures present).
|
||||||
|
var ErrDirectModeUnsupported = errors.New("direct mode does not support column/row security; requires the resolvespec_column_security/resolvespec_row_security stored procedures")
|
||||||
|
|
||||||
|
// TableNames defines all configurable table names used by Direct-mode SQL
|
||||||
|
// in the security package. Override individual fields to remap to custom
|
||||||
|
// table names. Use DefaultTableNames() for baseline defaults, and
|
||||||
|
// MergeTableNames() to apply partial overrides.
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTableNames returns a TableNames with all default table names.
|
||||||
|
func DefaultTableNames() *TableNames {
|
||||||
|
return &TableNames{
|
||||||
|
Users: "users",
|
||||||
|
UserSessions: "user_sessions",
|
||||||
|
TokenBlacklist: "token_blacklist",
|
||||||
|
UserTOTPBackupCodes: "user_totp_backup_codes",
|
||||||
|
UserPasskeyCredentials: "user_passkey_credentials",
|
||||||
|
UserPasswordResets: "user_password_resets",
|
||||||
|
OAuthClients: "oauth_clients",
|
||||||
|
OAuthCodes: "oauth_codes",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeTableNames returns a copy of base with any non-empty fields from override applied.
|
||||||
|
// If override is nil, a copy of base is returned.
|
||||||
|
func MergeTableNames(base, override *TableNames) *TableNames {
|
||||||
|
if override == nil {
|
||||||
|
copied := *base
|
||||||
|
return &copied
|
||||||
|
}
|
||||||
|
merged := *base
|
||||||
|
if override.Users != "" {
|
||||||
|
merged.Users = override.Users
|
||||||
|
}
|
||||||
|
if override.UserSessions != "" {
|
||||||
|
merged.UserSessions = override.UserSessions
|
||||||
|
}
|
||||||
|
if override.TokenBlacklist != "" {
|
||||||
|
merged.TokenBlacklist = override.TokenBlacklist
|
||||||
|
}
|
||||||
|
if override.UserTOTPBackupCodes != "" {
|
||||||
|
merged.UserTOTPBackupCodes = override.UserTOTPBackupCodes
|
||||||
|
}
|
||||||
|
if override.UserPasskeyCredentials != "" {
|
||||||
|
merged.UserPasskeyCredentials = override.UserPasskeyCredentials
|
||||||
|
}
|
||||||
|
if override.UserPasswordResets != "" {
|
||||||
|
merged.UserPasswordResets = override.UserPasswordResets
|
||||||
|
}
|
||||||
|
if override.OAuthClients != "" {
|
||||||
|
merged.OAuthClients = override.OAuthClients
|
||||||
|
}
|
||||||
|
if override.OAuthCodes != "" {
|
||||||
|
merged.OAuthCodes = override.OAuthCodes
|
||||||
|
}
|
||||||
|
return &merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateTableNames checks that all non-empty fields in names are valid SQL identifiers.
|
||||||
|
func ValidateTableNames(names *TableNames) error {
|
||||||
|
v := reflect.ValueOf(names).Elem()
|
||||||
|
typ := v.Type()
|
||||||
|
for i := 0; i < v.NumField(); i++ {
|
||||||
|
field := v.Field(i)
|
||||||
|
if field.Kind() != reflect.String {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val := field.String()
|
||||||
|
if val != "" && !validSQLIdentifier.MatchString(val) {
|
||||||
|
return fmt.Errorf("TableNames.%s contains invalid characters: %q", typ.Field(i).Name, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTableNames merges an optional override with defaults.
|
||||||
|
func resolveTableNames(override *TableNames) *TableNames {
|
||||||
|
return MergeTableNames(DefaultTableNames(), override)
|
||||||
|
}
|
||||||
+103
-6
@@ -1,11 +1,13 @@
|
|||||||
package security
|
package security
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DatabaseTwoFactorProvider implements TwoFactorAuthProvider using PostgreSQL stored procedures
|
// DatabaseTwoFactorProvider implements TwoFactorAuthProvider using PostgreSQL stored procedures
|
||||||
@@ -13,8 +15,13 @@ import (
|
|||||||
// See totp_database_schema.sql for procedure definitions
|
// See totp_database_schema.sql for procedure definitions
|
||||||
type DatabaseTwoFactorProvider struct {
|
type DatabaseTwoFactorProvider struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
dbMu sync.RWMutex
|
||||||
|
dbFactory func() (*sql.DB, error)
|
||||||
totpGen *TOTPGenerator
|
totpGen *TOTPGenerator
|
||||||
sqlNames *SQLNames
|
sqlNames *SQLNames
|
||||||
|
tableNames *TableNames
|
||||||
|
queryMode QueryMode
|
||||||
|
capability *dbCapability
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDatabaseTwoFactorProvider creates a new database-backed 2FA provider
|
// NewDatabaseTwoFactorProvider creates a new database-backed 2FA provider
|
||||||
@@ -26,9 +33,66 @@ func NewDatabaseTwoFactorProvider(db *sql.DB, config *TwoFactorConfig, names ...
|
|||||||
db: db,
|
db: db,
|
||||||
totpGen: NewTOTPGenerator(config),
|
totpGen: NewTOTPGenerator(config),
|
||||||
sqlNames: resolveSQLNames(names...),
|
sqlNames: resolveSQLNames(names...),
|
||||||
|
tableNames: DefaultTableNames(),
|
||||||
|
capability: newDBCapability(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithDBFactory configures a factory used to reopen the database connection if it is closed.
|
||||||
|
func (p *DatabaseTwoFactorProvider) WithDBFactory(factory func() (*sql.DB, error)) *DatabaseTwoFactorProvider {
|
||||||
|
p.dbFactory = factory
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTableNames configures Direct-mode table names. If names is nil, defaults are used.
|
||||||
|
func (p *DatabaseTwoFactorProvider) WithTableNames(names *TableNames) *DatabaseTwoFactorProvider {
|
||||||
|
p.tableNames = resolveTableNames(names)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithQueryMode selects stored-procedure vs Direct-mode SQL (default ModeAuto).
|
||||||
|
func (p *DatabaseTwoFactorProvider) WithQueryMode(mode QueryMode) *DatabaseTwoFactorProvider {
|
||||||
|
p.queryMode = mode
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DatabaseTwoFactorProvider) getDB() *sql.DB {
|
||||||
|
p.dbMu.RLock()
|
||||||
|
defer p.dbMu.RUnlock()
|
||||||
|
return p.db
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DatabaseTwoFactorProvider) reconnectDB() error {
|
||||||
|
if p.dbFactory == nil {
|
||||||
|
return fmt.Errorf("no db factory configured for reconnect")
|
||||||
|
}
|
||||||
|
newDB, err := p.dbFactory()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.dbMu.Lock()
|
||||||
|
p.db = newDB
|
||||||
|
p.dbMu.Unlock()
|
||||||
|
if p.capability != nil {
|
||||||
|
p.capability.reset()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DatabaseTwoFactorProvider) runDBOpWithReconnect(run func(*sql.DB) error) error {
|
||||||
|
db := p.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return fmt.Errorf("database connection is nil")
|
||||||
|
}
|
||||||
|
err := run(db)
|
||||||
|
if isDBClosed(err) {
|
||||||
|
if reconnErr := p.reconnectDB(); reconnErr == nil {
|
||||||
|
err = run(p.getDB())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Generate2FASecret creates a new secret for a user
|
// Generate2FASecret creates a new secret for a user
|
||||||
func (p *DatabaseTwoFactorProvider) Generate2FASecret(userID int, issuer, accountName string) (*TwoFactorSecret, error) {
|
func (p *DatabaseTwoFactorProvider) Generate2FASecret(userID int, issuer, accountName string) (*TwoFactorSecret, error) {
|
||||||
secret, err := p.totpGen.GenerateSecret()
|
secret, err := p.totpGen.GenerateSecret()
|
||||||
@@ -72,12 +136,17 @@ func (p *DatabaseTwoFactorProvider) Enable2FA(userID int, secret string, backupC
|
|||||||
return fmt.Errorf("failed to marshal backup codes: %w", err)
|
return fmt.Errorf("failed to marshal backup codes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPEnable) {
|
||||||
|
return p.enable2FADirect(ctx, userID, secret, hashedCodes)
|
||||||
|
}
|
||||||
|
|
||||||
// Call stored procedure
|
// Call stored procedure
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
|
|
||||||
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1, $2, $3::jsonb)`, p.sqlNames.TOTPEnable)
|
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1, $2, $3::jsonb)`, p.sqlNames.TOTPEnable)
|
||||||
err = p.db.QueryRow(query, userID, secret, string(codesJSON)).Scan(&success, &errorMsg)
|
err = p.getDB().QueryRow(query, userID, secret, string(codesJSON)).Scan(&success, &errorMsg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("enable 2FA query failed: %w", err)
|
return fmt.Errorf("enable 2FA query failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -94,11 +163,16 @@ func (p *DatabaseTwoFactorProvider) Enable2FA(userID int, secret string, backupC
|
|||||||
|
|
||||||
// Disable2FA deactivates 2FA for a user
|
// Disable2FA deactivates 2FA for a user
|
||||||
func (p *DatabaseTwoFactorProvider) Disable2FA(userID int) error {
|
func (p *DatabaseTwoFactorProvider) Disable2FA(userID int) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPDisable) {
|
||||||
|
return p.disable2FADirect(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
|
|
||||||
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1)`, p.sqlNames.TOTPDisable)
|
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1)`, p.sqlNames.TOTPDisable)
|
||||||
err := p.db.QueryRow(query, userID).Scan(&success, &errorMsg)
|
err := p.getDB().QueryRow(query, userID).Scan(&success, &errorMsg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("disable 2FA query failed: %w", err)
|
return fmt.Errorf("disable 2FA query failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -115,12 +189,17 @@ func (p *DatabaseTwoFactorProvider) Disable2FA(userID int) error {
|
|||||||
|
|
||||||
// Get2FAStatus checks if user has 2FA enabled
|
// Get2FAStatus checks if user has 2FA enabled
|
||||||
func (p *DatabaseTwoFactorProvider) Get2FAStatus(userID int) (bool, error) {
|
func (p *DatabaseTwoFactorProvider) Get2FAStatus(userID int) (bool, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPGetStatus) {
|
||||||
|
return p.get2FAStatusDirect(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
var enabled bool
|
var enabled bool
|
||||||
|
|
||||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_enabled FROM %s($1)`, p.sqlNames.TOTPGetStatus)
|
query := fmt.Sprintf(`SELECT p_success, p_error, p_enabled FROM %s($1)`, p.sqlNames.TOTPGetStatus)
|
||||||
err := p.db.QueryRow(query, userID).Scan(&success, &errorMsg, &enabled)
|
err := p.getDB().QueryRow(query, userID).Scan(&success, &errorMsg, &enabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("get 2FA status query failed: %w", err)
|
return false, fmt.Errorf("get 2FA status query failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -137,12 +216,17 @@ func (p *DatabaseTwoFactorProvider) Get2FAStatus(userID int) (bool, error) {
|
|||||||
|
|
||||||
// Get2FASecret retrieves the user's 2FA secret
|
// Get2FASecret retrieves the user's 2FA secret
|
||||||
func (p *DatabaseTwoFactorProvider) Get2FASecret(userID int) (string, error) {
|
func (p *DatabaseTwoFactorProvider) Get2FASecret(userID int) (string, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPGetSecret) {
|
||||||
|
return p.get2FASecretDirect(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
var secret sql.NullString
|
var secret sql.NullString
|
||||||
|
|
||||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_secret FROM %s($1)`, p.sqlNames.TOTPGetSecret)
|
query := fmt.Sprintf(`SELECT p_success, p_error, p_secret FROM %s($1)`, p.sqlNames.TOTPGetSecret)
|
||||||
err := p.db.QueryRow(query, userID).Scan(&success, &errorMsg, &secret)
|
err := p.getDB().QueryRow(query, userID).Scan(&success, &errorMsg, &secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("get 2FA secret query failed: %w", err)
|
return "", fmt.Errorf("get 2FA secret query failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -175,6 +259,14 @@ func (p *DatabaseTwoFactorProvider) GenerateBackupCodes(userID int, count int) (
|
|||||||
hashedCodes[i] = hex.EncodeToString(hash[:])
|
hashedCodes[i] = hex.EncodeToString(hash[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPRegenerateBackup) {
|
||||||
|
if err := p.regenerateBackupCodesDirect(ctx, userID, hashedCodes); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return codes, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to JSON array
|
// Convert to JSON array
|
||||||
codesJSON, err := json.Marshal(hashedCodes)
|
codesJSON, err := json.Marshal(hashedCodes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -186,7 +278,7 @@ func (p *DatabaseTwoFactorProvider) GenerateBackupCodes(userID int, count int) (
|
|||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
|
|
||||||
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1, $2::jsonb)`, p.sqlNames.TOTPRegenerateBackup)
|
query := fmt.Sprintf(`SELECT p_success, p_error FROM %s($1, $2::jsonb)`, p.sqlNames.TOTPRegenerateBackup)
|
||||||
err = p.db.QueryRow(query, userID, string(codesJSON)).Scan(&success, &errorMsg)
|
err = p.getDB().QueryRow(query, userID, string(codesJSON)).Scan(&success, &errorMsg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("regenerate backup codes query failed: %w", err)
|
return nil, fmt.Errorf("regenerate backup codes query failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -208,12 +300,17 @@ func (p *DatabaseTwoFactorProvider) ValidateBackupCode(userID int, code string)
|
|||||||
hash := sha256.Sum256([]byte(code))
|
hash := sha256.Sum256([]byte(code))
|
||||||
codeHash := hex.EncodeToString(hash[:])
|
codeHash := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if !p.capability.ShouldUseProcedure(ctx, p.queryMode, p.getDB(), p.sqlNames.TOTPValidateBackupCode) {
|
||||||
|
return p.validateBackupCodeDirect(ctx, userID, codeHash)
|
||||||
|
}
|
||||||
|
|
||||||
var success bool
|
var success bool
|
||||||
var errorMsg sql.NullString
|
var errorMsg sql.NullString
|
||||||
var valid bool
|
var valid bool
|
||||||
|
|
||||||
query := fmt.Sprintf(`SELECT p_success, p_error, p_valid FROM %s($1, $2)`, p.sqlNames.TOTPValidateBackupCode)
|
query := fmt.Sprintf(`SELECT p_success, p_error, p_valid FROM %s($1, $2)`, p.sqlNames.TOTPValidateBackupCode)
|
||||||
err := p.db.QueryRow(query, userID, codeHash).Scan(&success, &errorMsg, &valid)
|
err := p.getDB().QueryRow(query, userID, codeHash).Scan(&success, &errorMsg, &valid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("validate backup code query failed: %w", err)
|
return false, fmt.Errorf("validate backup code query failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
Vendored
+151
@@ -0,0 +1,151 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Direct-mode implementations mirroring the resolvespec_totp_* stored
|
||||||
|
// procedures in database_schema.sql using plain SQL against TableNames.
|
||||||
|
|
||||||
|
func (p *DatabaseTwoFactorProvider) enable2FADirect(ctx context.Context, userID int, secret string, hashedCodes []string) error {
|
||||||
|
return p.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
updQuery := rewritePlaceholders(db, fmt.Sprintf(
|
||||||
|
`UPDATE %s SET totp_secret = ?, totp_enabled = ?, totp_enabled_at = ? WHERE id = ?`, p.tableNames.Users))
|
||||||
|
res, err := db.ExecContext(ctx, updQuery, secret, true, time.Now(), userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows, err := res.RowsAffected(); err != nil {
|
||||||
|
return err
|
||||||
|
} else if rows == 0 {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes))
|
||||||
|
if _, err := db.ExecContext(ctx, delQuery, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
insQuery := rewritePlaceholders(db, fmt.Sprintf(`INSERT INTO %s (user_id, code_hash) VALUES (?, ?)`, p.tableNames.UserTOTPBackupCodes))
|
||||||
|
for _, hash := range hashedCodes {
|
||||||
|
if _, err := db.ExecContext(ctx, insQuery, userID, hash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DatabaseTwoFactorProvider) disable2FADirect(ctx context.Context, userID int) error {
|
||||||
|
return p.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET totp_secret = NULL, totp_enabled = ? WHERE id = ?`, p.tableNames.Users))
|
||||||
|
res, err := db.ExecContext(ctx, updQuery, false, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows, err := res.RowsAffected(); err != nil {
|
||||||
|
return err
|
||||||
|
} else if rows == 0 {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes))
|
||||||
|
_, err = db.ExecContext(ctx, delQuery, userID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DatabaseTwoFactorProvider) get2FAStatusDirect(ctx context.Context, userID int) (bool, error) {
|
||||||
|
var enabled sql.NullBool
|
||||||
|
err := p.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT totp_enabled FROM %s WHERE id = ?`, p.tableNames.Users))
|
||||||
|
return db.QueryRowContext(ctx, query, userID).Scan(&enabled)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("get 2FA status query failed: %w", err)
|
||||||
|
}
|
||||||
|
return enabled.Bool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DatabaseTwoFactorProvider) get2FASecretDirect(ctx context.Context, userID int) (string, error) {
|
||||||
|
var secret sql.NullString
|
||||||
|
var enabled sql.NullBool
|
||||||
|
err := p.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT totp_secret, totp_enabled FROM %s WHERE id = ?`, p.tableNames.Users))
|
||||||
|
return db.QueryRowContext(ctx, query, userID).Scan(&secret, &enabled)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("get 2FA secret query failed: %w", err)
|
||||||
|
}
|
||||||
|
if !enabled.Bool {
|
||||||
|
return "", fmt.Errorf("TOTP not enabled for user")
|
||||||
|
}
|
||||||
|
return secret.String, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DatabaseTwoFactorProvider) regenerateBackupCodesDirect(ctx context.Context, userID int, hashedCodes []string) error {
|
||||||
|
return p.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
var count int
|
||||||
|
checkQuery := rewritePlaceholders(db, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE id = ? AND totp_enabled = ?`, p.tableNames.Users))
|
||||||
|
if err := db.QueryRowContext(ctx, checkQuery, userID, true).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return fmt.Errorf("user not found or TOTP not enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
delQuery := rewritePlaceholders(db, fmt.Sprintf(`DELETE FROM %s WHERE user_id = ?`, p.tableNames.UserTOTPBackupCodes))
|
||||||
|
if _, err := db.ExecContext(ctx, delQuery, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
insQuery := rewritePlaceholders(db, fmt.Sprintf(`INSERT INTO %s (user_id, code_hash) VALUES (?, ?)`, p.tableNames.UserTOTPBackupCodes))
|
||||||
|
for _, hash := range hashedCodes {
|
||||||
|
if _, err := db.ExecContext(ctx, insQuery, userID, hash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DatabaseTwoFactorProvider) validateBackupCodeDirect(ctx context.Context, userID int, codeHash string) (bool, error) {
|
||||||
|
var valid bool
|
||||||
|
err := p.runDBOpWithReconnect(func(db *sql.DB) error {
|
||||||
|
var codeID int
|
||||||
|
var used bool
|
||||||
|
query := rewritePlaceholders(db, fmt.Sprintf(`SELECT id, used FROM %s WHERE user_id = ? AND code_hash = ?`, p.tableNames.UserTOTPBackupCodes))
|
||||||
|
err := db.QueryRowContext(ctx, query, userID, codeHash).Scan(&codeID, &used)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
valid = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if used {
|
||||||
|
return fmt.Errorf("backup code already used")
|
||||||
|
}
|
||||||
|
|
||||||
|
updQuery := rewritePlaceholders(db, fmt.Sprintf(`UPDATE %s SET used = ?, used_at = ? WHERE id = ?`, p.tableNames.UserTOTPBackupCodes))
|
||||||
|
if _, err := db.ExecContext(ctx, updQuery, true, time.Now(), codeID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
valid = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return valid, nil
|
||||||
|
}
|
||||||
Vendored
+4
-4
@@ -1,7 +1,10 @@
|
|||||||
|
# git.warky.dev/wdevs/relspecgo v1.0.62
|
||||||
|
## explicit; go 1.25.7
|
||||||
|
git.warky.dev/wdevs/relspecgo/pkg/sqltypes
|
||||||
# github.com/beorn7/perks v1.0.1
|
# github.com/beorn7/perks v1.0.1
|
||||||
## explicit; go 1.11
|
## explicit; go 1.11
|
||||||
github.com/beorn7/perks/quantile
|
github.com/beorn7/perks/quantile
|
||||||
# github.com/bitechdev/ResolveSpec v1.1.24
|
# github.com/bitechdev/ResolveSpec v1.1.26
|
||||||
## explicit; go 1.25.7
|
## explicit; go 1.25.7
|
||||||
github.com/bitechdev/ResolveSpec/pkg/cache
|
github.com/bitechdev/ResolveSpec/pkg/cache
|
||||||
github.com/bitechdev/ResolveSpec/pkg/common
|
github.com/bitechdev/ResolveSpec/pkg/common
|
||||||
@@ -15,7 +18,6 @@ github.com/bitechdev/ResolveSpec/pkg/modelregistry
|
|||||||
github.com/bitechdev/ResolveSpec/pkg/reflection
|
github.com/bitechdev/ResolveSpec/pkg/reflection
|
||||||
github.com/bitechdev/ResolveSpec/pkg/resolvespec
|
github.com/bitechdev/ResolveSpec/pkg/resolvespec
|
||||||
github.com/bitechdev/ResolveSpec/pkg/security
|
github.com/bitechdev/ResolveSpec/pkg/security
|
||||||
github.com/bitechdev/ResolveSpec/pkg/spectypes
|
|
||||||
# github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c
|
# github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c
|
||||||
## explicit; go 1.18
|
## explicit; go 1.18
|
||||||
github.com/bradfitz/gomemcache/memcache
|
github.com/bradfitz/gomemcache/memcache
|
||||||
@@ -184,8 +186,6 @@ github.com/redis/go-redis/v9/internal/routing
|
|||||||
github.com/redis/go-redis/v9/internal/util
|
github.com/redis/go-redis/v9/internal/util
|
||||||
github.com/redis/go-redis/v9/maintnotifications
|
github.com/redis/go-redis/v9/maintnotifications
|
||||||
github.com/redis/go-redis/v9/push
|
github.com/redis/go-redis/v9/push
|
||||||
# github.com/rogpeppe/go-internal v1.14.1
|
|
||||||
## explicit; go 1.23
|
|
||||||
# github.com/sagikazarmark/locafero v0.12.0
|
# github.com/sagikazarmark/locafero v0.12.0
|
||||||
## explicit; go 1.23.0
|
## explicit; go 1.23.0
|
||||||
github.com/sagikazarmark/locafero
|
github.com/sagikazarmark/locafero
|
||||||
|
|||||||
Reference in New Issue
Block a user