Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d991ad4bdb | ||
|
|
57d74bf601 | ||
|
|
a16283efd7 | ||
|
|
7c68a3499a |
@@ -2,6 +2,49 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## v0.1.0-preview.27 — 2026-09-10
|
||||
|
||||
- Add independent `cms` values and a `cmssqlite` adapter for named taxonomies,
|
||||
stable terms, immutable revision associations and bidirectional editorial
|
||||
links. Applications retain typed content, templates, permissions and commerce.
|
||||
- Join caller-owned SQLite transactions so content, publication pointers and
|
||||
application audits commit together. Schema installation is explicit and
|
||||
independent of authentication schema 11, which remains unchanged.
|
||||
- Preserve term URL aliases and historical associations on rename/retirement.
|
||||
Query only published snapshots, with namespace isolation and bounded keyset
|
||||
pagination. The application still checks each target's current availability.
|
||||
- Include executable integration guidance and regressions for conflicts,
|
||||
rollback, draft isolation, reverse discovery, aliases and retirement. The
|
||||
consumer exercised native editing and publication behind trusted local TLS.
|
||||
|
||||
## v0.1.0-preview.26 — 2026-09-05
|
||||
|
||||
- Add optional self-profile readers and revision-checked username/display-name
|
||||
writes. Recheck the active session, account and expected revision atomically
|
||||
with a secret-free audit; preserve immutable user identity and ownership.
|
||||
- Username edits revoke other sessions but preserve the acting session. A
|
||||
password-confirmed write can require the exact verified credential hash,
|
||||
rejecting a concurrent password reset. Applications own reauthentication,
|
||||
operation-bound passkey approval, CSRF/origin checks and rate/concurrency limits.
|
||||
- Add explicit SQLite schema 11 for monotonic profile revisions. Existing rows
|
||||
begin at revision 1; startup with migrations disabled rejects older schemas.
|
||||
Do not run older writers against schema 11 as a database rollback strategy.
|
||||
- Email changes are deliberately absent; pending-address verification and mail
|
||||
delivery are separate work. Test invalid/restricted sessions, collisions,
|
||||
concurrent/stale edits, audit rollback, restart and schema-10 migration.
|
||||
|
||||
## v0.1.0-preview.25 — 2026-09-05
|
||||
|
||||
- Add optional, credential-free user and organization directory readers for
|
||||
application-authorized instance administration. They include inactive/pending
|
||||
users and personal/archived organizations independently of membership.
|
||||
- Bound literal searches and stable-ID pagination to at most 200 returned
|
||||
records. Queries do not load passwords, sessions, recovery material, invitations
|
||||
or role grants; they grant no authority. Applications must authorize each read.
|
||||
- Cover pagination, renamed records, literal SQL/wildcard input, Unicode text,
|
||||
invalid bounds and cancellation. Schema 10 and existing repository contracts
|
||||
remain unchanged; source exports include the new optional interfaces/readers.
|
||||
|
||||
## v0.1.0-preview.24 — 2026-09-05
|
||||
|
||||
- Add explicit owner-managed profile and optimistic membership operations.
|
||||
|
||||
@@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and
|
||||
deployment. Adopt one boundary at a time; Go compiles and links only the
|
||||
packages you import.
|
||||
|
||||
> **Public preview:** `v0.1.0-preview.24`. APIs may change before a stable
|
||||
> **Public preview:** `v0.1.0-preview.27`. APIs may change before a stable
|
||||
> release. Linux is the maintained release platform.
|
||||
|
||||
## Why Web Foundations?
|
||||
@@ -44,6 +44,7 @@ packages you import.
|
||||
| Recovery codes and owner-assisted recovery | [`authrecovery`](authrecovery) |
|
||||
| Private SQLite persistence | [`authsqlite`](authsqlite) |
|
||||
| Bounded media and private local blobs | [`media`](media) + [`medialocal`](medialocal) |
|
||||
| Typed editorial categories and related-content discovery | [`cms`](cms) + [`cmssqlite`](cmssqlite); [integration guide](docs/CMS.md) |
|
||||
| Organizations, teams, and invitations | [`organizations`](organizations) |
|
||||
| Organization-scoped roles and temporary access | [`access`](access) |
|
||||
| Application-classified request abuse | [`abuse`](abuse) |
|
||||
@@ -57,14 +58,14 @@ owns—and, just as importantly, what remains application policy.
|
||||
Pin the preview in an application module:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web@v0.1.0-preview.24
|
||||
go get gamertan.com/web@v0.1.0-preview.27
|
||||
go mod verify
|
||||
```
|
||||
|
||||
An application may name the first package it intends to adopt:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.24
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.27
|
||||
```
|
||||
|
||||
The version belongs to the `gamertan.com/web` module. See the
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<!-- SPDX-License-Identifier: MPL-2.0 -->
|
||||
|
||||
# Current work
|
||||
|
||||
## CMS classification and relationships
|
||||
|
||||
Implement `cms` and `cmssqlite` primitives for Gamertan's existing typed content:
|
||||
named flat taxonomies, stable terms with rename history, immutable revision
|
||||
associations, explicit publication and bidirectional related-content queries.
|
||||
Keep authorization, audits, application schemas, templates and commerce policy
|
||||
with their callers. Writes join caller-owned SQLite transactions.
|
||||
|
||||
Implemented: validated values, explicit schema, caller-owned transactions,
|
||||
immutable associations, publication pointers, term aliases and public readers.
|
||||
Verified: focused race tests for scope isolation, conflicts, draft/public
|
||||
transitions, retirement, rename collisions, pagination and rollback. Gamertan's
|
||||
consumer HTTP regressions now exercise real content/catalog/case-study routing,
|
||||
draft isolation, restore, role/CSRF checks and unchanged catalog data.
|
||||
|
||||
Consolidated evidence: the full package race suite passed; association fuzzing
|
||||
passed 698,275 executions. Dependency/module and license checks passed. Vet has
|
||||
only the existing vendored COSE diagnostic documented by `scripts/verify.sh`.
|
||||
The consumer's full Go suite and native trusted-TLS editor checks passed.
|
||||
|
||||
The consolidated `scripts/verify.sh` passed, including all 126 allowlisted
|
||||
public-export paths, exported-package compilation, full tests/race, the existing
|
||||
vet diagnostic exception, dependency/license checks and the starter build.
|
||||
|
||||
Release candidate: `v0.1.0-preview.27`. Next: publish these verified bytes and
|
||||
pin the consumer immutably. No unrelated auth changes in this slice.
|
||||
|
||||
Status: implemented and consolidated verification/export complete. Consumer
|
||||
deployment remains separately recorded in its repository. Release history stays
|
||||
in CHANGELOG; publication is confirmed by the remote tag, not a checkbox here.
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var ErrDirectoryQuery = errors.New("auth: invalid directory query")
|
||||
|
||||
// UserDirectoryQuery requests a bounded instance-wide identity listing. Search
|
||||
// is literal text, not a query language. AfterID is an exclusive stable-ID cursor;
|
||||
// Limit defaults to 50 and may not exceed 200.
|
||||
type UserDirectoryQuery struct {
|
||||
Search, AfterID string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type UserDirectoryPage struct {
|
||||
Users []User
|
||||
NextID string
|
||||
}
|
||||
|
||||
// UserDirectoryRepository is an optional administrative read capability, not an
|
||||
// extension of ordinary authentication. Callers MUST authorize instance-wide
|
||||
// identity access before each call. Results include incomplete/inactive accounts
|
||||
// but never credentials, session material, recovery codes or permission grants.
|
||||
// Pagination is a current view, not a snapshot across requests.
|
||||
type UserDirectoryRepository interface {
|
||||
UserDirectory(context.Context, UserDirectoryQuery) (UserDirectoryPage, error)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProfileInput = errors.New("auth: invalid profile change")
|
||||
ErrProfileAccess = errors.New("auth: profile session is unavailable")
|
||||
ErrProfileConflict = errors.New("auth: profile changed; reload before editing")
|
||||
ErrUsernameUnavailable = errors.New("auth: username is unavailable")
|
||||
)
|
||||
|
||||
// OwnProfile contains mutable identity, not credentials or organization roles.
|
||||
// Revision is independent of timestamps and increases for every profile edit.
|
||||
type OwnProfile struct {
|
||||
UserID, Username, Email, DisplayName string
|
||||
Revision int64
|
||||
}
|
||||
|
||||
// ProfileEdit is a trusted repository command, not an HTTP input model. The
|
||||
// application must authenticate the session, validate CSRF/origin and rate-limit
|
||||
// mutations. Username edits additionally require recent reauthentication (and
|
||||
// any account-specific MFA). For password reauthentication, supply the verified
|
||||
// hash so a concurrent password reset invalidates the write. After verified
|
||||
// passkey approval, leave it empty. Do not log or serialize this command.
|
||||
type ProfileEdit struct {
|
||||
UserID string
|
||||
SessionDigest [32]byte
|
||||
ExpectedRevision int64
|
||||
Field, Value string
|
||||
ExpectedPasswordHash string
|
||||
}
|
||||
|
||||
// NormalizeProfileValue validates only supported fields. Email is deliberately
|
||||
// absent: verified mailbox changes need a separate pending/confirmation flow.
|
||||
func NormalizeProfileValue(field, value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
switch field {
|
||||
case "username":
|
||||
if !identifierPattern.MatchString(value) {
|
||||
return "", ErrProfileInput
|
||||
}
|
||||
case "display_name":
|
||||
if value == "" || len(value) > 128 || !utf8.ValidString(value) {
|
||||
return "", ErrProfileInput
|
||||
}
|
||||
for _, r := range value {
|
||||
if unicode.IsControl(r) {
|
||||
return "", ErrProfileInput
|
||||
}
|
||||
}
|
||||
default:
|
||||
return "", ErrProfileInput
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// OwnProfileRepository is optional; no change to the authentication Repository
|
||||
// interface is required. It derives access from the current session, never from
|
||||
// a site-wide administrator flag. Implementations atomically recheck identity,
|
||||
// session and revision, mutate one field, and append the audit. Username edits
|
||||
// revoke other sessions but preserve the acting session. They never reassign
|
||||
// stable IDs, memberships, passkeys, billing identities or historical records.
|
||||
type OwnProfileRepository interface {
|
||||
OwnProfile(context.Context, [32]byte, time.Time) (OwnProfile, error)
|
||||
UpdateOwnProfile(context.Context, ProfileEdit, AuditEvent) (OwnProfile, error)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeProfileValue(t *testing.T) {
|
||||
for _, value := range []struct{ field, value, want string }{
|
||||
{"username", " Reader.One ", "Reader.One"}, {"display_name", " Émilie ★ ", "Émilie ★"},
|
||||
} {
|
||||
got, err := NormalizeProfileValue(value.field, value.value)
|
||||
if err != nil || got != value.want {
|
||||
t.Fatalf("normalization: %q %v", got, err)
|
||||
}
|
||||
}
|
||||
for _, value := range []struct{ field, value string }{
|
||||
{"email", "new@example.test"}, {"role", "owner"}, {"username", "a"}, {"username", "foo@bar"},
|
||||
{"display_name", ""}, {"display_name", "hello\x00world"}, {"display_name", "hello\nworld"}, {"display_name", string([]byte{0xff})},
|
||||
} {
|
||||
if _, err := NormalizeProfileValue(value.field, value.value); err == nil {
|
||||
t.Fatalf("invalid field accepted: %s", value.field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzProfileValue(f *testing.F) {
|
||||
f.Add("username", "reader.one")
|
||||
f.Add("display_name", "Émilie")
|
||||
f.Add("email", "a@example.test")
|
||||
f.Fuzz(func(t *testing.T, field, value string) {
|
||||
normal, err := NormalizeProfileValue(field, value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(normal) == 0 || len(normal) > 128 {
|
||||
t.Fatal("unbounded value")
|
||||
}
|
||||
again, err := NormalizeProfileValue(field, normal)
|
||||
if err != nil || again != normal {
|
||||
t.Fatal("unstable normalization")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authsqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/organizations"
|
||||
)
|
||||
|
||||
var _ auth.UserDirectoryRepository = (*Store)(nil)
|
||||
var _ organizations.DirectoryRepository = (*Store)(nil)
|
||||
|
||||
// UserDirectory is an administrative read; the adapter cannot infer application
|
||||
// authorization. Search covers ID, username, email and display name. SQLite LIKE
|
||||
// folds ASCII case; non-ASCII display-name text matches with its original case.
|
||||
func (store *Store) UserDirectory(ctx context.Context, query auth.UserDirectoryQuery) (auth.UserDirectoryPage, error) {
|
||||
pattern, limit, valid := directoryQuery(query.Search, query.AfterID, query.Limit)
|
||||
if !valid {
|
||||
return auth.UserDirectoryPage{}, auth.ErrDirectoryQuery
|
||||
}
|
||||
rows, err := store.db.QueryContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,registration_pending,created_at,updated_at
|
||||
FROM gwf_users WHERE id>? AND (?='' OR id=? OR username_normalized LIKE ? ESCAPE '\' OR email_normalized LIKE ? ESCAPE '\' OR display_name LIKE ? ESCAPE '\')
|
||||
ORDER BY id LIMIT ?`, query.AfterID, strings.TrimSpace(query.Search), strings.TrimSpace(query.Search), strings.ToLower(pattern), strings.ToLower(pattern), pattern, limit+1)
|
||||
if err != nil {
|
||||
return auth.UserDirectoryPage{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
page := auth.UserDirectoryPage{Users: make([]auth.User, 0, limit)}
|
||||
for rows.Next() {
|
||||
user, err := scanPasskeyUser(rows)
|
||||
if err != nil {
|
||||
return auth.UserDirectoryPage{}, err
|
||||
}
|
||||
page.Users = append(page.Users, user)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return auth.UserDirectoryPage{}, err
|
||||
}
|
||||
if len(page.Users) > limit {
|
||||
page.Users = page.Users[:limit]
|
||||
page.NextID = page.Users[limit-1].ID
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// OrganizationDirectory reads all personal/business and active/archived records.
|
||||
// It does not join membership, grant access, or choose a merchant. Search covers
|
||||
// exact ID and literal slug/name text using SQLite's ASCII case folding.
|
||||
func (store *Store) OrganizationDirectory(ctx context.Context, query organizations.DirectoryQuery) (organizations.DirectoryPage, error) {
|
||||
pattern, limit, valid := directoryQuery(query.Search, query.AfterID, query.Limit)
|
||||
if !valid {
|
||||
return organizations.DirectoryPage{}, organizations.ErrDirectoryQuery
|
||||
}
|
||||
rows, err := store.db.QueryContext(ctx, `SELECT id,slug,name,status,personal,revision,created_at,updated_at
|
||||
FROM gwf_organizations WHERE id>? AND (?='' OR id=? OR slug LIKE ? ESCAPE '\' OR name LIKE ? ESCAPE '\')
|
||||
ORDER BY id LIMIT ?`, query.AfterID, strings.TrimSpace(query.Search), strings.TrimSpace(query.Search), pattern, pattern, limit+1)
|
||||
if err != nil {
|
||||
return organizations.DirectoryPage{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
page := organizations.DirectoryPage{Organizations: make([]organizations.Organization, 0, limit)}
|
||||
for rows.Next() {
|
||||
var value organizations.Organization
|
||||
var created, updated int64
|
||||
if err = rows.Scan(&value.ID, &value.Slug, &value.Name, &value.Status, &value.Personal, &value.Revision, &created, &updated); err != nil {
|
||||
return organizations.DirectoryPage{}, err
|
||||
}
|
||||
value.CreatedAt, value.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
|
||||
page.Organizations = append(page.Organizations, value)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return organizations.DirectoryPage{}, err
|
||||
}
|
||||
if len(page.Organizations) > limit {
|
||||
page.Organizations = page.Organizations[:limit]
|
||||
page.NextID = page.Organizations[limit-1].ID
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func directoryQuery(search, after string, limit int) (string, int, bool) {
|
||||
if !text(search, 128, true) || after != "" && !opaqueID(after) || limit < 0 || limit > 200 {
|
||||
return "", 0, false
|
||||
}
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
// Wildcards and the escape character are literal user text, never operators.
|
||||
pattern := "%" + strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(strings.TrimSpace(search)) + "%"
|
||||
return pattern, limit, true
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authsqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gamertan.com/web/auth"
|
||||
"gamertan.com/web/organizations"
|
||||
)
|
||||
|
||||
func TestInstanceDirectoriesAreBoundedCredentialFreeAndIndependentOfMembership(t *testing.T) {
|
||||
store, err := Open(filepath.Join(t.TempDir(), "directory.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
ctx := t.Context()
|
||||
for index := 0; index < 205; index++ {
|
||||
id := fmt.Sprintf("record-%03d", index)
|
||||
status := []string{"active", "suspended", "disabled"}[index%3]
|
||||
_, err = store.db.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,registration_pending,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,1,1)`, id, id, id, id+"@example.test", id+"@example.test", "Person "+id, status, index%2, index%5 == 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = store.db.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,status,revision,created_at,updated_at) VALUES(?,?,?,?,?,1,1,1)`, id, id, "Business "+id, index%2, []string{"active", "archived"}[index%2])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
users, err := store.UserDirectory(ctx, auth.UserDirectoryQuery{})
|
||||
if err != nil || len(users.Users) != 50 || users.NextID != "record-049" {
|
||||
t.Fatalf("default users: %d %q %v", len(users.Users), users.NextID, err)
|
||||
}
|
||||
if !users.Users[0].RegistrationPending || users.Users[1].Status != "suspended" || !users.Users[1].PasswordChangeRequired || users.Users[2].Status != "disabled" {
|
||||
t.Fatal("administrative account states were hidden")
|
||||
}
|
||||
encoded, _ := json.Marshal(users)
|
||||
for _, secret := range []string{"password_hash", "Session", "Digest", "Credential", "Recovery"} {
|
||||
if strings.Contains(string(encoded), secret) {
|
||||
t.Fatalf("directory leaked credential field %s", secret)
|
||||
}
|
||||
}
|
||||
for _, size := range []int{1, 50, 200} {
|
||||
userAfter, orgAfter, count := "", "", 0
|
||||
for {
|
||||
users, err := store.UserDirectory(ctx, auth.UserDirectoryQuery{AfterID: userAfter, Limit: size})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orgs, err := store.OrganizationDirectory(ctx, organizations.DirectoryQuery{AfterID: orgAfter, Limit: size})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(users.Users) != len(orgs.Organizations) || len(users.Users) > size {
|
||||
t.Fatal("invalid page bound")
|
||||
}
|
||||
for index, user := range users.Users {
|
||||
want := fmt.Sprintf("record-%03d", count)
|
||||
if user.ID != want || orgs.Organizations[index].ID != want {
|
||||
t.Fatalf("pagination skipped/duplicated %s", want)
|
||||
}
|
||||
count++
|
||||
}
|
||||
if users.NextID == "" || orgs.NextID == "" {
|
||||
if users.NextID != orgs.NextID || count != 205 {
|
||||
t.Fatalf("early end: %d", count)
|
||||
}
|
||||
break
|
||||
}
|
||||
userAfter, orgAfter = users.NextID, orgs.NextID
|
||||
}
|
||||
}
|
||||
orgs, err := store.OrganizationDirectory(ctx, organizations.DirectoryQuery{Limit: 2})
|
||||
if err != nil || orgs.Organizations[0].Personal || !orgs.Organizations[1].Personal || orgs.Organizations[1].Status != "archived" {
|
||||
t.Fatal("personal/archived organizations omitted")
|
||||
}
|
||||
// A display-name change cannot move a record behind a stable-ID cursor.
|
||||
if _, err = store.db.Exec(`UPDATE gwf_users SET display_name='AAA' WHERE id='record-050'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next, err := store.UserDirectory(ctx, auth.UserDirectoryQuery{AfterID: "record-049", Limit: 1})
|
||||
if err != nil || next.Users[0].ID != "record-050" {
|
||||
t.Fatal("name change disturbed cursor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceDirectoryLiteralSearchValidationAndCancellation(t *testing.T) {
|
||||
store, err := Open(filepath.Join(t.TempDir(), "directory.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
_, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,created_at,updated_at) VALUES('person-001','Alice','alice','Alice@example.test','alice@example.test','Élodie 50%_\ works','active',1,1)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = store.db.Exec(`INSERT INTO gwf_organizations(id,slug,name,personal,status,revision,created_at,updated_at) VALUES('company-001','alice-company','Élodie 50%_\ works',0,'active',1,1,1)`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, search := range []string{"", " ALICE ", "example.test", "person-001", "Élodie", `50%_\`} {
|
||||
page, err := store.UserDirectory(t.Context(), auth.UserDirectoryQuery{Search: search})
|
||||
if err != nil || len(page.Users) != 1 || page.NextID != "" {
|
||||
t.Errorf("user literal search %q: %#v %v", search, page, err)
|
||||
}
|
||||
}
|
||||
for _, search := range []string{"", "ALICE", "company-001", "Élodie", `50%_\`} {
|
||||
page, err := store.OrganizationDirectory(t.Context(), organizations.DirectoryQuery{Search: search})
|
||||
if err != nil || len(page.Organizations) != 1 || page.NextID != "" {
|
||||
t.Errorf("organization literal search %q: %#v %v", search, page, err)
|
||||
}
|
||||
}
|
||||
for _, search := range []string{"absent", "%' OR 1=1 --", "%_%", "\\_%"} {
|
||||
users, err := store.UserDirectory(t.Context(), auth.UserDirectoryQuery{Search: search})
|
||||
if err != nil || users.Users == nil || len(users.Users) != 0 {
|
||||
t.Errorf("nonliteral user search %q", search)
|
||||
}
|
||||
orgs, err := store.OrganizationDirectory(t.Context(), organizations.DirectoryQuery{Search: search})
|
||||
if err != nil || orgs.Organizations == nil || len(orgs.Organizations) != 0 {
|
||||
t.Errorf("nonliteral org search %q", search)
|
||||
}
|
||||
}
|
||||
for _, query := range []auth.UserDirectoryQuery{{Search: strings.Repeat("a", 129)}, {Search: "bad\x00value"}, {Search: "bad\nvalue"}, {Search: "\xff"}, {AfterID: "bad/id"}, {AfterID: strings.Repeat("a", 129)}, {Limit: -1}, {Limit: 201}} {
|
||||
if _, err := store.UserDirectory(t.Context(), query); !errors.Is(err, auth.ErrDirectoryQuery) {
|
||||
t.Errorf("invalid user query accepted: %#v %v", query, err)
|
||||
}
|
||||
if _, err := store.OrganizationDirectory(t.Context(), organizations.DirectoryQuery{Search: query.Search, AfterID: query.AfterID, Limit: query.Limit}); !errors.Is(err, organizations.ErrDirectoryQuery) {
|
||||
t.Errorf("invalid org query accepted: %#v %v", query, err)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
if _, err = store.UserDirectory(ctx, auth.UserDirectoryQuery{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("user cancellation: %v", err)
|
||||
}
|
||||
if _, err = store.OrganizationDirectory(ctx, organizations.DirectoryQuery{}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("organization cancellation: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authsqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/auth"
|
||||
"modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var _ auth.OwnProfileRepository = (*Store)(nil)
|
||||
|
||||
const ownProfileQuery = `SELECT u.id,u.username,u.email,u.display_name,u.profile_revision
|
||||
FROM gwf_users u JOIN gwf_auth_sessions s ON s.user_id=u.id
|
||||
WHERE s.token_hash=? AND s.expires_at>? AND u.status='active'
|
||||
AND u.registration_pending=0 AND u.password_change_required=0`
|
||||
|
||||
func scanOwnProfile(row interface{ Scan(...any) error }) (auth.OwnProfile, error) {
|
||||
var profile auth.OwnProfile
|
||||
err := row.Scan(&profile.UserID, &profile.Username, &profile.Email, &profile.DisplayName, &profile.Revision)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return auth.OwnProfile{}, auth.ErrProfileAccess
|
||||
}
|
||||
return profile, err
|
||||
}
|
||||
|
||||
func (store *Store) OwnProfile(ctx context.Context, session [32]byte, now time.Time) (auth.OwnProfile, error) {
|
||||
if zeroDigest(session) || now.IsZero() {
|
||||
return auth.OwnProfile{}, auth.ErrProfileAccess
|
||||
}
|
||||
return scanOwnProfile(store.db.QueryRowContext(ctx, ownProfileQuery, session[:], now.Unix()))
|
||||
}
|
||||
|
||||
func (store *Store) UpdateOwnProfile(ctx context.Context, change auth.ProfileEdit, audit auth.AuditEvent) (auth.OwnProfile, error) {
|
||||
value, err := auth.NormalizeProfileValue(change.Field, change.Value)
|
||||
if err != nil || !opaqueID(change.UserID) || zeroDigest(change.SessionDigest) || change.ExpectedRevision < 1 || change.ExpectedRevision == math.MaxInt64 ||
|
||||
!validAuditEvent(audit) || audit.ActorUserID != change.UserID || audit.ResourceType != "user" || audit.ResourceID != change.UserID || audit.Action != "auth.profile."+change.Field ||
|
||||
change.ExpectedPasswordHash != "" && (change.Field != "username" || len(change.ExpectedPasswordHash) > 1024) {
|
||||
return auth.OwnProfile{}, auth.ErrProfileInput
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// This first statement takes the writer lock and tests the real current
|
||||
// session/account/revision together. No read-before-write lock upgrade race.
|
||||
set := `display_name=?`
|
||||
args := []any{value}
|
||||
if change.Field == "username" {
|
||||
set = `username=?,username_normalized=?`
|
||||
args = append(args, normalize(value))
|
||||
}
|
||||
args = append(args, audit.CreatedAt.Unix(), change.UserID, change.ExpectedRevision, change.SessionDigest[:], audit.CreatedAt.Unix(), change.ExpectedPasswordHash, change.ExpectedPasswordHash)
|
||||
result, err := tx.ExecContext(ctx, `UPDATE gwf_users SET `+set+`,profile_revision=profile_revision+1,updated_at=MAX(updated_at,?)
|
||||
WHERE id=? AND profile_revision=? AND status='active' AND registration_pending=0 AND password_change_required=0
|
||||
AND EXISTS (SELECT 1 FROM gwf_auth_sessions WHERE user_id=gwf_users.id AND token_hash=? AND expires_at>?)
|
||||
AND (?='' OR EXISTS (SELECT 1 FROM gwf_password_credentials WHERE user_id=gwf_users.id AND password_hash=?))`, args...)
|
||||
if err != nil {
|
||||
var constraint *sqlite.Error
|
||||
if errors.As(err, &constraint) && constraint.Code() == 2067 {
|
||||
return auth.OwnProfile{}, auth.ErrUsernameUnavailable
|
||||
}
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
changed, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
if changed != 1 {
|
||||
profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix()))
|
||||
if err != nil {
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
if profile.UserID != change.UserID {
|
||||
return auth.OwnProfile{}, auth.ErrProfileAccess
|
||||
}
|
||||
return auth.OwnProfile{}, auth.ErrProfileConflict
|
||||
}
|
||||
if change.Field == "username" {
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=? AND token_hash<>?`, change.UserID, change.SessionDigest[:]); err != nil {
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
}
|
||||
if err = appendAudit(ctx, tx, audit); err != nil {
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
profile, err := scanOwnProfile(tx.QueryRowContext(ctx, ownProfileQuery, change.SessionDigest[:], audit.CreatedAt.Unix()))
|
||||
if err != nil {
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return auth.OwnProfile{}, err
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authsqlite
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/auth"
|
||||
)
|
||||
|
||||
type profileFixture struct {
|
||||
store *Store
|
||||
path string
|
||||
now time.Time
|
||||
user auth.User
|
||||
session, other auth.Session
|
||||
}
|
||||
|
||||
func newProfileFixture(t *testing.T) profileFixture {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "identity.sqlite")
|
||||
store, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
user := auth.User{ID: "profile-user", Username: "profile.reader", Email: "profile@example.test", DisplayName: "Profile Reader", Status: "active", CreatedAt: now, UpdatedAt: now}
|
||||
if err = store.CreateUser(t.Context(), user, "fixture-hash"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session := auth.Session{UserID: user.ID, Digest: sha256.Sum256([]byte("acting-session")), CreatedAt: now, LastSeenAt: now, ExpiresAt: now.Add(time.Hour)}
|
||||
other := session
|
||||
other.Digest = sha256.Sum256([]byte("other-session"))
|
||||
for _, s := range []auth.Session{session, other} {
|
||||
if err = store.CreateSession(t.Context(), s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return profileFixture{store, path, now, user, session, other}
|
||||
}
|
||||
|
||||
func (f profileFixture) change(field, value string, revision int64) (auth.ProfileEdit, auth.AuditEvent) {
|
||||
return auth.ProfileEdit{UserID: f.user.ID, SessionDigest: f.session.Digest, ExpectedRevision: revision, Field: field, Value: value},
|
||||
auth.AuditEvent{ID: "profile-audit-" + field, ActorUserID: f.user.ID, Action: "auth.profile." + field, ResourceType: "user", ResourceID: f.user.ID, Summary: "Own profile field changed", CreatedAt: f.now}
|
||||
}
|
||||
|
||||
func TestOwnProfileStableIdentityAndSessionPolicy(t *testing.T) {
|
||||
f := newProfileFixture(t)
|
||||
initial, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now)
|
||||
if err != nil || initial.Revision != 1 {
|
||||
t.Fatalf("initial revision: %d %v", initial.Revision, err)
|
||||
}
|
||||
change, audit := f.change("display_name", " Émilie ★ ", 1)
|
||||
updated, err := f.store.UpdateOwnProfile(t.Context(), change, audit)
|
||||
if err != nil || updated.DisplayName != "Émilie ★" || updated.Revision != 2 || updated.UserID != initial.UserID || updated.Username != initial.Username || updated.Email != initial.Email {
|
||||
t.Fatalf("display update: %+v %v", updated, err)
|
||||
}
|
||||
if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil {
|
||||
t.Fatal("display edit revoked session", err)
|
||||
}
|
||||
if _, err = f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) {
|
||||
t.Fatalf("stale: %v", err)
|
||||
}
|
||||
change, audit = f.change("username", "new.reader", 2)
|
||||
change.ExpectedPasswordHash = "fixture-hash"
|
||||
updated, err = f.store.UpdateOwnProfile(t.Context(), change, audit)
|
||||
if err != nil || updated.Username != "new.reader" || updated.Revision != 3 || updated.UserID != initial.UserID || updated.Email != initial.Email {
|
||||
t.Fatalf("username update: %+v %v", updated, err)
|
||||
}
|
||||
if _, err = f.store.OwnProfile(t.Context(), f.other.Digest, f.now); !errors.Is(err, auth.ErrProfileAccess) {
|
||||
t.Fatalf("other session survived: %v", err)
|
||||
}
|
||||
user, hash, err := f.store.CredentialByIdentifier(t.Context(), "NEW.READER")
|
||||
if err != nil || user.ID != initial.UserID || hash != "fixture-hash" {
|
||||
t.Fatal("credential identity changed", err)
|
||||
}
|
||||
var count int
|
||||
if err = f.store.db.QueryRow(`SELECT count(*) FROM gwf_audit_events WHERE actor_user_id=? AND resource_id=?`, f.user.ID, f.user.ID).Scan(&count); err != nil || count != 2 {
|
||||
t.Fatalf("audits: %d %v", count, err)
|
||||
}
|
||||
reopened, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
if recovered, err := reopened.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || recovered != updated {
|
||||
t.Fatalf("restart: %+v %v", recovered, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnProfileAuthorizationAndRollback(t *testing.T) {
|
||||
for _, test := range []struct{ name, sql string }{
|
||||
{"revoked-session", `DELETE FROM gwf_auth_sessions`},
|
||||
{"expired-session", `UPDATE gwf_auth_sessions SET expires_at=1`},
|
||||
{"suspended", `UPDATE gwf_users SET status='suspended'`},
|
||||
{"disabled", `UPDATE gwf_users SET status='disabled'`},
|
||||
{"registration-pending", `UPDATE gwf_users SET registration_pending=1`},
|
||||
{"password-change", `UPDATE gwf_users SET password_change_required=1`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
f := newProfileFixture(t)
|
||||
if _, err := f.store.db.Exec(test.sql); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
change, audit := f.change("display_name", "not allowed", 1)
|
||||
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) {
|
||||
t.Fatalf("access: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
f := newProfileFixture(t)
|
||||
change, audit := f.change("username", "new.reader", 1)
|
||||
change.UserID = "another-user"
|
||||
audit.ActorUserID = change.UserID
|
||||
audit.ResourceID = change.UserID
|
||||
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileAccess) {
|
||||
t.Fatalf("foreign user: %v", err)
|
||||
}
|
||||
change, audit = f.change("username", "new.reader", 1)
|
||||
change.ExpectedPasswordHash = "old-verified-hash"
|
||||
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrProfileConflict) {
|
||||
t.Fatalf("changed password: %v", err)
|
||||
}
|
||||
change.ExpectedPasswordHash = "fixture-hash"
|
||||
if err := f.store.AppendAudit(t.Context(), audit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); err == nil {
|
||||
t.Fatal("duplicate audit accepted")
|
||||
}
|
||||
if profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now); err != nil || profile.Revision != 1 || profile.Username != f.user.Username {
|
||||
t.Fatalf("rollback: %+v %v", profile, err)
|
||||
}
|
||||
if _, err := f.store.OwnProfile(t.Context(), f.other.Digest, f.now); err != nil {
|
||||
t.Fatal("audit failure revoked session", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnProfileUniquenessConcurrencyAndMigration(t *testing.T) {
|
||||
f := newProfileFixture(t)
|
||||
otherUser := f.user
|
||||
otherUser.ID = "another-user"
|
||||
otherUser.Username = "another.reader"
|
||||
otherUser.Email = "another@example.test"
|
||||
if err := f.store.CreateUser(t.Context(), otherUser, "fixture-hash"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
change, audit := f.change("username", "ANOTHER.READER", 1)
|
||||
if _, err := f.store.UpdateOwnProfile(t.Context(), change, audit); !errors.Is(err, auth.ErrUsernameUnavailable) {
|
||||
t.Fatalf("unique name: %v", err)
|
||||
}
|
||||
second, err := OpenWithOptions(f.path, OpenOptions{Migrate: false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer second.Close()
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
for _, store := range []*Store{f.store, second} {
|
||||
wg.Add(1)
|
||||
go func(store *Store) {
|
||||
defer wg.Done()
|
||||
change, audit := f.change("display_name", "New Name", 1)
|
||||
_, err := store.UpdateOwnProfile(t.Context(), change, audit)
|
||||
results <- err
|
||||
}(store)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
success, conflict := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
success++
|
||||
} else if errors.Is(err, auth.ErrProfileConflict) {
|
||||
conflict++
|
||||
} else {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if success != 1 || conflict != 1 {
|
||||
t.Fatalf("concurrent writes: %d successes, %d conflicts", success, conflict)
|
||||
}
|
||||
// Recreate the actual previous schema without rewriting its identity rows.
|
||||
if _, err = f.store.db.Exec(`ALTER TABLE gwf_users DROP COLUMN profile_revision`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = f.store.db.Exec(`DELETE FROM gamertan_web_migrations WHERE version=11`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version, err := f.store.CurrentSchema(t.Context()); err != nil || version != 10 {
|
||||
t.Fatalf("prior schema: %d %v", version, err)
|
||||
}
|
||||
if err = f.store.RequireCurrentSchema(t.Context()); err == nil {
|
||||
t.Fatal("startup accepted old schema")
|
||||
}
|
||||
if err = f.store.Migrate(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile, err := f.store.OwnProfile(t.Context(), f.session.Digest, f.now)
|
||||
if err != nil || profile.UserID != f.user.ID || profile.Email != f.user.Email || profile.DisplayName != "New Name" || profile.Revision != 1 {
|
||||
t.Fatalf("migration: %+v %v", profile, err)
|
||||
}
|
||||
if err = f.store.Migrate(t.Context()); err != nil {
|
||||
t.Fatal("idempotent migration", err)
|
||||
}
|
||||
}
|
||||
@@ -351,7 +351,8 @@ func TestRoleInvitationMigrationPreservesLegacyAndRequiresExplicitMigration(t *t
|
||||
// Reconstruct the previous invitation schema in this disposable database.
|
||||
if _, err = f.store.db.Exec(`ALTER TABLE gwf_organization_invitations DROP COLUMN direct_roles_json;
|
||||
ALTER TABLE gwf_organization_invitations DROP COLUMN required_owner_role;
|
||||
DELETE FROM gamertan_web_migrations WHERE version=10`); err != nil {
|
||||
ALTER TABLE gwf_users DROP COLUMN profile_revision;
|
||||
DELETE FROM gamertan_web_migrations WHERE version>=10`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = f.store.RequireCurrentSchema(t.Context()); err == nil {
|
||||
|
||||
+5
-1
@@ -77,7 +77,7 @@ func OpenWithOptions(path string, options OpenOptions) (*Store, error) {
|
||||
return store, nil
|
||||
}
|
||||
|
||||
const SchemaVersion = 10
|
||||
const SchemaVersion = 11
|
||||
|
||||
func (store *Store) CurrentSchema(ctx context.Context) (int, error) {
|
||||
var exists int
|
||||
@@ -179,6 +179,7 @@ func (store *Store) Migrate(ctx context.Context) error {
|
||||
table, column, definition string
|
||||
}{
|
||||
{"gwf_users", "registration_pending", `INTEGER NOT NULL DEFAULT 0 CHECK(registration_pending IN (0,1))`},
|
||||
{"gwf_users", "profile_revision", `INTEGER NOT NULL DEFAULT 1 CHECK(profile_revision > 0)`},
|
||||
{"gwf_organizations", "status", `TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','archived'))`},
|
||||
{"gwf_organizations", "revision", `INTEGER NOT NULL DEFAULT 1 CHECK(revision > 0)`},
|
||||
{"gwf_organizations", "updated_at", `INTEGER NOT NULL DEFAULT 0`},
|
||||
@@ -244,6 +245,9 @@ func (store *Store) Migrate(ctx context.Context) error {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(10,?)`, time.Now().UTC().Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(11,?)`, time.Now().UTC().Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package cms supplies bounded classification and editorial relationship values.
|
||||
// It does not define content types, layouts, authorization, or commerce policy.
|
||||
// Applications own those decisions and publish exact immutable revisions.
|
||||
package cms
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("cms: invalid value")
|
||||
ErrConflict = errors.New("cms: revision or slug conflict")
|
||||
ErrNotFound = errors.New("cms: not found")
|
||||
identifier = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$`)
|
||||
slug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
kind = regexp.MustCompile(`^[a-z][a-z0-9-]{0,39}$`)
|
||||
)
|
||||
|
||||
const MaxTerms = 24
|
||||
const MaxLinks = 16
|
||||
|
||||
// Reference names an application-owned resource, never a mutable URL or title.
|
||||
type Reference struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func ValidID(value string) bool { return identifier.MatchString(value) }
|
||||
func ValidSlug(value string) bool { return len(value) <= 80 && slug.MatchString(value) }
|
||||
func ValidText(value string, max int) bool {
|
||||
return utf8.ValidString(value) && len(value) <= max && strings.TrimSpace(value) == value && !strings.ContainsFunc(value, unicode.IsControl)
|
||||
}
|
||||
func (r Reference) Validate() error {
|
||||
if !kind.MatchString(r.Kind) || !ValidID(r.ID) {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Associations are an immutable revision's term memberships and explicit links.
|
||||
// Reverse discovery reads the same links; callers must not store a second edge.
|
||||
type Associations struct {
|
||||
Terms []string `json:"terms,omitempty"`
|
||||
Links []Reference `json:"links,omitempty"`
|
||||
}
|
||||
|
||||
func (a Associations) Validate(source Reference) error {
|
||||
if source.Validate() != nil || len(a.Terms) > MaxTerms || len(a.Links) > MaxLinks {
|
||||
return ErrInvalid
|
||||
}
|
||||
terms := map[string]bool{}
|
||||
for _, id := range a.Terms {
|
||||
if !ValidID(id) || terms[id] {
|
||||
return ErrInvalid
|
||||
}
|
||||
terms[id] = true
|
||||
}
|
||||
links := map[Reference]bool{}
|
||||
for _, ref := range a.Links {
|
||||
if ref.Validate() != nil || ref == source || links[ref] {
|
||||
return ErrInvalid
|
||||
}
|
||||
links[ref] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Taxonomy is an editor-named vocabulary, not a database-defined content type.
|
||||
// Slug is fixed after creation; Name/Description and availability may change.
|
||||
type Taxonomy struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Revision int64 `json:"revision"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
func (v Taxonomy) Validate() error {
|
||||
if !ValidID(v.ID) || !ValidSlug(v.Slug) || v.Name == "" || !ValidText(v.Name, 120) || !ValidText(v.Description, 500) || v.Revision < 1 {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Term identity survives renaming and retirement. Retirement hides discovery;
|
||||
// it does not rewrite old associations or imply removal of related resources.
|
||||
type Term struct {
|
||||
ID string `json:"id"`
|
||||
TaxonomyID string `json:"taxonomy_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Revision int64 `json:"revision"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
func (v Term) Validate() error {
|
||||
if !ValidID(v.ID) || !ValidID(v.TaxonomyID) || !ValidSlug(v.Slug) || v.Name == "" || !ValidText(v.Name, 120) || !ValidText(v.Description, 500) || v.Revision < 1 {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ResourceVersion struct {
|
||||
Reference
|
||||
Revision int64 `json:"revision"`
|
||||
}
|
||||
type Page struct {
|
||||
Items []ResourceVersion `json:"items"`
|
||||
Next *Reference `json:"next,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
package cms
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAssociationsBoundsAndIdentity(t *testing.T) {
|
||||
source := Reference{Kind: "project", ID: "project-one"}
|
||||
valid := Associations{Terms: []string{"go"}, Links: []Reference{{Kind: "news", ID: "launch"}}}
|
||||
if valid.Validate(source) != nil {
|
||||
t.Fatal("valid association rejected")
|
||||
}
|
||||
for _, value := range []Associations{
|
||||
{Terms: []string{"go", "go"}}, {Terms: []string{"../private"}},
|
||||
{Links: []Reference{source}}, {Links: []Reference{{Kind: "news", ID: "launch"}, {Kind: "news", ID: "launch"}}},
|
||||
{Links: []Reference{{Kind: "<script>", ID: "safe"}}},
|
||||
{Terms: make([]string, MaxTerms+1)}, {Links: make([]Reference, MaxLinks+1)},
|
||||
} {
|
||||
if value.Validate(source) == nil {
|
||||
t.Fatalf("accepted %#v", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestTaxonomyAndTermText(t *testing.T) {
|
||||
tax := Taxonomy{ID: "categories", Slug: "categories", Name: "Categories", Revision: 1, Active: true}
|
||||
if tax.Validate() != nil {
|
||||
t.Fatal("valid taxonomy")
|
||||
}
|
||||
for _, name := range []string{"", " bad", "bad\nname", string([]byte{255}), strings.Repeat("a", 121)} {
|
||||
v := tax
|
||||
v.Name = name
|
||||
if v.Validate() == nil {
|
||||
t.Fatal("accepted invalid name")
|
||||
}
|
||||
}
|
||||
term := Term{ID: "go", TaxonomyID: tax.ID, Slug: "go", Name: "Go", Revision: 1, Active: true}
|
||||
if term.Validate() != nil {
|
||||
t.Fatal("valid term")
|
||||
}
|
||||
term.Slug = "../go"
|
||||
if term.Validate() == nil {
|
||||
t.Fatal("unsafe slug")
|
||||
}
|
||||
}
|
||||
func FuzzAssociations(f *testing.F) {
|
||||
f.Add(`{"terms":["go"],"links":[{"kind":"news","id":"launch"}]}`)
|
||||
f.Fuzz(func(t *testing.T, raw string) {
|
||||
if len(raw) > 20000 {
|
||||
return
|
||||
}
|
||||
var value Associations
|
||||
if json.Unmarshal([]byte(raw), &value) != nil {
|
||||
return
|
||||
}
|
||||
if value.Validate(Reference{Kind: "project", ID: "one"}) == nil {
|
||||
b, e := json.Marshal(value)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var again Associations
|
||||
if json.Unmarshal(b, &again) != nil || again.Validate(Reference{Kind: "project", ID: "one"}) != nil {
|
||||
t.Fatal("round trip")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cmssqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"gamertan.com/web/cms"
|
||||
)
|
||||
|
||||
// PutRevision appends once. Unknown terms are rejected; retired terms remain
|
||||
// usable when copying historical revisions. Editors decide whether to admit new
|
||||
// retired-term assignments. Link targets are validated by the application: they
|
||||
// can live in a different content/catalog store. A link grants no authority.
|
||||
func PutRevision(ctx context.Context, tx *sql.Tx, scope string, ref cms.Reference, revision int64, a cms.Associations) error {
|
||||
if !cms.ValidID(scope) || revision < 1 || a.Validate(ref) != nil {
|
||||
return cms.ErrInvalid
|
||||
}
|
||||
for _, id := range a.Terms {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_terms WHERE scope=? AND id=?`, scope, id).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return cms.ErrNotFound
|
||||
}
|
||||
}
|
||||
b, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_resources(scope,kind,id) VALUES(?,?,?) ON CONFLICT DO NOTHING`, scope, ref.Kind, ref.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO gwf_cms_associations(scope,kind,id,revision,document) VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, ref.Kind, ref.ID, revision, string(b))
|
||||
if err = oneRow(result, err); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range a.Terms {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_memberships(scope,kind,id,revision,term_id) VALUES(?,?,?,?,?)`, scope, ref.Kind, ref.ID, revision, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, target := range a.Links {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_links(scope,kind,id,revision,target_kind,target_id) VALUES(?,?,?,?,?,?)`, scope, ref.Kind, ref.ID, revision, target.Kind, target.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPublished selects an exact revision or zero to unpublish. Call inside the
|
||||
// same transaction as the application's publication transition and audit. Draft
|
||||
// saves do not call this function. Product availability is also checked by the
|
||||
// consuming application; publication is not entitlement or payment authority.
|
||||
func SetPublished(ctx context.Context, tx *sql.Tx, scope string, ref cms.Reference, revision int64) error {
|
||||
if !cms.ValidID(scope) || ref.Validate() != nil || revision < 0 {
|
||||
return cms.ErrInvalid
|
||||
}
|
||||
if revision > 0 {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=? AND revision=?`, scope, ref.Kind, ref.ID, revision).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return cms.ErrNotFound
|
||||
}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE gwf_cms_resources SET published_revision=? WHERE scope=? AND kind=? AND id=?`, revision, scope, ref.Kind, ref.ID)
|
||||
return oneRow(result, err)
|
||||
}
|
||||
|
||||
func (r *Reader) Revision(ctx context.Context, ref cms.Reference, revision int64) (cms.Associations, error) {
|
||||
if ref.Validate() != nil || revision < 1 {
|
||||
return cms.Associations{}, cms.ErrInvalid
|
||||
}
|
||||
var raw string
|
||||
err := r.db.QueryRowContext(ctx, `SELECT document FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=? AND revision=?`, r.scope, ref.Kind, ref.ID, revision).Scan(&raw)
|
||||
if err != nil {
|
||||
return cms.Associations{}, notFound(err)
|
||||
}
|
||||
var a cms.Associations
|
||||
if len(raw) > 16384 || json.Unmarshal([]byte(raw), &a) != nil || a.Validate(ref) != nil {
|
||||
return cms.Associations{}, cms.ErrInvalid
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
func (r *Reader) LatestRevision(ctx context.Context, ref cms.Reference) (int64, error) {
|
||||
if ref.Validate() != nil {
|
||||
return 0, cms.ErrInvalid
|
||||
}
|
||||
var revision sql.NullInt64
|
||||
err := r.db.QueryRowContext(ctx, `SELECT MAX(revision) FROM gwf_cms_associations WHERE scope=? AND kind=? AND id=?`, r.scope, ref.Kind, ref.ID).Scan(&revision)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !revision.Valid {
|
||||
return 0, cms.ErrNotFound
|
||||
}
|
||||
return revision.Int64, nil
|
||||
}
|
||||
func (r *Reader) PublishedRevision(ctx context.Context, ref cms.Reference) (int64, error) {
|
||||
if ref.Validate() != nil {
|
||||
return 0, cms.ErrInvalid
|
||||
}
|
||||
var revision int64
|
||||
err := r.db.QueryRowContext(ctx, `SELECT published_revision FROM gwf_cms_resources WHERE scope=? AND kind=? AND id=? AND published_revision>0`, r.scope, ref.Kind, ref.ID).Scan(&revision)
|
||||
return revision, notFound(err)
|
||||
}
|
||||
|
||||
// Members returns published members of an active term and taxonomy. Pagination
|
||||
// happens after publication filtering, with stable kind/ID cursors.
|
||||
func (r *Reader) Members(ctx context.Context, termID string, after *cms.Reference, limit int) (cms.Page, error) {
|
||||
if !cms.ValidID(termID) {
|
||||
return cms.Page{}, cms.ErrInvalid
|
||||
}
|
||||
query := `SELECT DISTINCT r.kind,r.id,r.published_revision FROM gwf_cms_memberships m JOIN gwf_cms_resources r ON r.scope=m.scope AND r.kind=m.kind AND r.id=m.id AND r.published_revision=m.revision JOIN gwf_cms_terms t ON t.scope=m.scope AND t.id=m.term_id JOIN gwf_cms_taxonomies x ON x.scope=t.scope AND x.id=t.taxonomy_id WHERE m.scope=? AND m.term_id=? AND r.published_revision>0 AND t.active=1 AND x.active=1`
|
||||
return r.page(ctx, query, []any{r.scope, termID}, after, limit)
|
||||
}
|
||||
|
||||
// Related returns both directions of explicit relationships between published
|
||||
// revisions. Shared taxonomy membership alone does not assert a relationship.
|
||||
func (r *Reader) Related(ctx context.Context, ref cms.Reference, after *cms.Reference, limit int) (cms.Page, error) {
|
||||
if ref.Validate() != nil {
|
||||
return cms.Page{}, cms.ErrInvalid
|
||||
}
|
||||
query := `WITH edges AS (
|
||||
SELECT l.target_kind AS kind,l.target_id AS id FROM gwf_cms_links l JOIN gwf_cms_resources s ON s.scope=l.scope AND s.kind=l.kind AND s.id=l.id AND s.published_revision=l.revision WHERE l.scope=? AND l.kind=? AND l.id=? AND s.published_revision>0
|
||||
UNION
|
||||
SELECT l.kind,l.id FROM gwf_cms_links l JOIN gwf_cms_resources s ON s.scope=l.scope AND s.kind=l.kind AND s.id=l.id AND s.published_revision=l.revision WHERE l.scope=? AND l.target_kind=? AND l.target_id=? AND s.published_revision>0
|
||||
) SELECT r.kind,r.id,r.published_revision FROM edges e JOIN gwf_cms_resources r ON r.kind=e.kind AND r.id=e.id WHERE r.scope=? AND r.published_revision>0 AND NOT(r.kind=? AND r.id=?) AND EXISTS(SELECT 1 FROM gwf_cms_resources origin WHERE origin.scope=? AND origin.kind=? AND origin.id=? AND origin.published_revision>0)`
|
||||
return r.page(ctx, query, []any{r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID, r.scope, ref.Kind, ref.ID}, after, limit)
|
||||
}
|
||||
|
||||
func (r *Reader) page(ctx context.Context, query string, args []any, after *cms.Reference, limit int) (cms.Page, error) {
|
||||
if limit < 1 || limit > 100 || (after != nil && after.Validate() != nil) {
|
||||
return cms.Page{}, cms.ErrInvalid
|
||||
}
|
||||
if after != nil {
|
||||
query += ` AND (r.kind>? OR (r.kind=? AND r.id>?))`
|
||||
args = append(args, after.Kind, after.Kind, after.ID)
|
||||
}
|
||||
query += ` ORDER BY r.kind,r.id LIMIT ?`
|
||||
args = append(args, limit+1)
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return cms.Page{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
p := cms.Page{Items: []cms.ResourceVersion{}}
|
||||
for rows.Next() {
|
||||
var v cms.ResourceVersion
|
||||
if err := rows.Scan(&v.Kind, &v.ID, &v.Revision); err != nil {
|
||||
return cms.Page{}, err
|
||||
}
|
||||
p.Items = append(p.Items, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return cms.Page{}, err
|
||||
}
|
||||
if len(p.Items) > limit {
|
||||
p.Items = p.Items[:limit]
|
||||
ref := p.Items[limit-1].Reference
|
||||
p.Next = &ref
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
package cmssqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"gamertan.com/web/cms"
|
||||
"gamertan.com/web/cmssqlite"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func Example() {
|
||||
ctx := context.Background()
|
||||
db, err := sql.Open("sqlite", ":memory:?_pragma=foreign_keys(1)")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer db.Close()
|
||||
db.SetMaxOpenConns(1)
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err = cmssqlite.CreateSchema(ctx, tx); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
const scope = "my-site"
|
||||
if err = cmssqlite.PutTaxonomy(ctx, tx, scope, cms.Taxonomy{ID: "topics", Slug: "topics", Name: "Topics", Revision: 1, Active: true}, 0); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err = cmssqlite.PutTerm(ctx, tx, scope, cms.Term{ID: "go", TaxonomyID: "topics", Slug: "go", Name: "Go", Revision: 1, Active: true}, 0); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ref := cms.Reference{Kind: "article", ID: "first-post"}
|
||||
// The application authorizes the writer and stores its content/audit in this
|
||||
// same transaction. Only publication advances the public association pointer.
|
||||
if err = cmssqlite.PutRevision(ctx, tx, scope, ref, 1, cms.Associations{Terms: []string{"go"}}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err = cmssqlite.SetPublished(ctx, tx, scope, ref, 1); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
reader, err := cmssqlite.New(db, scope)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
page, err := reader.Members(ctx, "go", nil, 20)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
fmt.Println(item.Kind, item.ID, item.Revision)
|
||||
}
|
||||
// Output: article first-post 1
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package cmssqlite stores cms values in an application's SQLite transaction.
|
||||
// Schema installation is explicit. Callers own database opening, migration
|
||||
// versions, authorization and audits. Every mutation must use a caller-owned
|
||||
// transaction, committing its domain change and audit together; never use a
|
||||
// pooled *sql.DB for multi-statement writes. Namespaces isolate application data.
|
||||
package cmssqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"gamertan.com/web/cms"
|
||||
)
|
||||
|
||||
type Queryer interface {
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
// Reader may use a database or read transaction. Writers below require *sql.Tx.
|
||||
type Reader struct {
|
||||
db Queryer
|
||||
scope string
|
||||
}
|
||||
|
||||
func New(db Queryer, scope string) (*Reader, error) {
|
||||
if db == nil || !cms.ValidID(scope) {
|
||||
return nil, cms.ErrInvalid
|
||||
}
|
||||
return &Reader{db: db, scope: scope}, nil
|
||||
}
|
||||
|
||||
// CreateSchema must be called from the application's explicit migration.
|
||||
// It never changes an existing publishing schema or starts a transaction.
|
||||
func CreateSchema(ctx context.Context, tx *sql.Tx) error {
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE IF NOT EXISTS gwf_cms_taxonomies (scope TEXT NOT NULL,id TEXT NOT NULL,slug TEXT NOT NULL,name TEXT NOT NULL,description TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),active INTEGER NOT NULL CHECK(active IN (0,1)),PRIMARY KEY(scope,id),UNIQUE(scope,slug))`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_cms_terms (scope TEXT NOT NULL,id TEXT NOT NULL,taxonomy_id TEXT NOT NULL,slug TEXT NOT NULL,name TEXT NOT NULL,description TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),active INTEGER NOT NULL CHECK(active IN (0,1)),PRIMARY KEY(scope,id),UNIQUE(scope,taxonomy_id,slug),FOREIGN KEY(scope,taxonomy_id) REFERENCES gwf_cms_taxonomies(scope,id))`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_cms_term_slugs (scope TEXT NOT NULL,taxonomy_id TEXT NOT NULL,slug TEXT NOT NULL,term_id TEXT NOT NULL,PRIMARY KEY(scope,taxonomy_id,slug),FOREIGN KEY(scope,term_id) REFERENCES gwf_cms_terms(scope,id))`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_cms_resources (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,published_revision INTEGER NOT NULL DEFAULT 0 CHECK(published_revision>=0),PRIMARY KEY(scope,kind,id))`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_cms_associations (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL CHECK(revision>0),document TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision),FOREIGN KEY(scope,kind,id) REFERENCES gwf_cms_resources(scope,kind,id))`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_cms_memberships (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL,term_id TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision,term_id),FOREIGN KEY(scope,kind,id,revision) REFERENCES gwf_cms_associations(scope,kind,id,revision),FOREIGN KEY(scope,term_id) REFERENCES gwf_cms_terms(scope,id))`,
|
||||
`CREATE INDEX IF NOT EXISTS gwf_cms_memberships_term ON gwf_cms_memberships(scope,term_id,kind,id,revision)`,
|
||||
`CREATE TABLE IF NOT EXISTS gwf_cms_links (scope TEXT NOT NULL,kind TEXT NOT NULL,id TEXT NOT NULL,revision INTEGER NOT NULL,target_kind TEXT NOT NULL,target_id TEXT NOT NULL,PRIMARY KEY(scope,kind,id,revision,target_kind,target_id),FOREIGN KEY(scope,kind,id,revision) REFERENCES gwf_cms_associations(scope,kind,id,revision))`,
|
||||
`CREATE INDEX IF NOT EXISTS gwf_cms_links_target ON gwf_cms_links(scope,target_kind,target_id,kind,id,revision)`,
|
||||
} {
|
||||
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
package cmssqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gamertan.com/web/cms"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func fixture(t *testing.T) (*sql.DB, *Reader) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "cms.sqlite")+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_txlock=immediate")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error { return CreateSchema(context.Background(), tx) })
|
||||
r, e := New(db, "merchant")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for _, scope := range []string{"merchant", "other"} {
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error {
|
||||
return PutTaxonomy(context.Background(), tx, scope, cms.Taxonomy{ID: "topics", Slug: "topics", Name: "Topics", Active: true, Revision: 1}, 0)
|
||||
})
|
||||
}
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error {
|
||||
return PutTerm(context.Background(), tx, "merchant", cms.Term{ID: "go", TaxonomyID: "topics", Slug: "go", Name: "Go", Active: true, Revision: 1}, 0)
|
||||
})
|
||||
return db, r
|
||||
}
|
||||
func mutate(t *testing.T, db *sql.DB, want error, fn func(*sql.Tx) error) {
|
||||
t.Helper()
|
||||
tx, e := db.BeginTx(context.Background(), nil)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
err := fn(tx)
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("mutation error %v, want %v", err, want)
|
||||
}
|
||||
if err == nil {
|
||||
if e = tx.Commit(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
func save(t *testing.T, db *sql.DB, ref cms.Reference, rev int64, a cms.Associations, publish bool) {
|
||||
t.Helper()
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error {
|
||||
if err := PutRevision(context.Background(), tx, "merchant", ref, rev, a); err != nil {
|
||||
return err
|
||||
}
|
||||
if publish {
|
||||
return SetPublished(context.Background(), tx, "merchant", ref, rev)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
func TestPublicationRelationshipsAndHistory(t *testing.T) {
|
||||
db, r := fixture(t)
|
||||
ctx := context.Background()
|
||||
project := cms.Reference{Kind: "project", ID: "hime"}
|
||||
news := cms.Reference{Kind: "news", ID: "launch"}
|
||||
product := cms.Reference{Kind: "product", ID: "support"}
|
||||
save(t, db, project, 1, cms.Associations{Terms: []string{"go"}}, true)
|
||||
save(t, db, news, 1, cms.Associations{Terms: []string{"go"}, Links: []cms.Reference{project}}, false)
|
||||
p, e := r.Related(ctx, project, nil, 10)
|
||||
if e != nil || len(p.Items) != 0 {
|
||||
t.Fatalf("draft leak: %+v %v", p, e)
|
||||
}
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 1) })
|
||||
for _, ref := range []cms.Reference{project, news} {
|
||||
p, e = r.Related(ctx, ref, nil, 10)
|
||||
if e != nil || len(p.Items) != 1 {
|
||||
t.Fatalf("reverse missing: %+v %v", p, e)
|
||||
}
|
||||
}
|
||||
save(t, db, product, 1, cms.Associations{}, true)
|
||||
save(t, db, news, 2, cms.Associations{Links: []cms.Reference{product}}, false)
|
||||
p, e = r.Related(ctx, project, nil, 10)
|
||||
if e != nil || len(p.Items) != 1 {
|
||||
t.Fatal("draft replaced published graph", e)
|
||||
}
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 2) })
|
||||
p, e = r.Related(ctx, project, nil, 10)
|
||||
if e != nil || len(p.Items) != 0 {
|
||||
t.Fatal("stale published edge", e)
|
||||
}
|
||||
p, e = r.Related(ctx, product, nil, 10)
|
||||
if e != nil || len(p.Items) != 1 {
|
||||
t.Fatal("missing new edge", e)
|
||||
}
|
||||
old, e := r.Revision(ctx, news, 1)
|
||||
if e != nil || old.Links[0] != project {
|
||||
t.Fatal("history changed", e)
|
||||
}
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", product, 0) })
|
||||
p, e = r.Related(ctx, news, nil, 10)
|
||||
if e != nil || len(p.Items) != 0 {
|
||||
t.Fatal("unpublished target leak", e)
|
||||
}
|
||||
p, e = r.Related(ctx, product, nil, 10)
|
||||
if e != nil || len(p.Items) != 0 {
|
||||
t.Fatal("unpublished source discovery", e)
|
||||
}
|
||||
// Restoring an old association is a new revision, not an overwritten row.
|
||||
save(t, db, news, 3, old, true)
|
||||
p, e = r.Related(ctx, project, nil, 10)
|
||||
if e != nil || len(p.Items) != 1 || p.Items[0].Revision != 3 {
|
||||
t.Fatal("restore", e)
|
||||
}
|
||||
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutRevision(ctx, tx, "merchant", news, 1, cms.Associations{}) })
|
||||
mutate(t, db, cms.ErrNotFound, func(tx *sql.Tx) error { return SetPublished(ctx, tx, "merchant", news, 999) })
|
||||
}
|
||||
func TestTermRenameRetirementAndScope(t *testing.T) {
|
||||
db, r := fixture(t)
|
||||
ctx := context.Background()
|
||||
ref := cms.Reference{Kind: "writing", ID: "essay"}
|
||||
save(t, db, ref, 1, cms.Associations{Terms: []string{"go"}}, true)
|
||||
term, e := r.Term(ctx, "go")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
term.Slug = "golang"
|
||||
term.Name = "Go language"
|
||||
term.Revision = 2
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", term, 1) })
|
||||
alias, e := r.TermBySlug(ctx, "topics", "go")
|
||||
if e != nil || alias.ID != term.ID || alias.Slug != "golang" {
|
||||
t.Fatal("alias", e)
|
||||
}
|
||||
stolen := cms.Term{ID: "stolen", TaxonomyID: "topics", Slug: "go", Name: "Other", Revision: 1, Active: true}
|
||||
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", stolen, 0) })
|
||||
duplicate := term
|
||||
duplicate.Revision = 1
|
||||
mutate(t, db, cms.ErrConflict, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", duplicate, 0) })
|
||||
other, _ := New(db, "other")
|
||||
if _, e = other.Term(ctx, "go"); !errors.Is(e, cms.ErrNotFound) {
|
||||
t.Fatal("cross scope term", e)
|
||||
}
|
||||
mutate(t, db, cms.ErrNotFound, func(tx *sql.Tx) error {
|
||||
return PutRevision(ctx, tx, "other", ref, 1, cms.Associations{Terms: []string{"go"}})
|
||||
})
|
||||
p, e := other.Members(ctx, "go", nil, 10)
|
||||
if e != nil || len(p.Items) != 0 {
|
||||
t.Fatal("scope leak", e)
|
||||
}
|
||||
p, e = r.Members(ctx, "go", nil, 10)
|
||||
if e != nil || len(p.Items) != 1 {
|
||||
t.Fatal("membership lost on rename", e)
|
||||
}
|
||||
term.Active = false
|
||||
term.Revision = 3
|
||||
mutate(t, db, nil, func(tx *sql.Tx) error { return PutTerm(ctx, tx, "merchant", term, 2) })
|
||||
p, e = r.Members(ctx, "go", nil, 10)
|
||||
if e != nil || len(p.Items) != 0 {
|
||||
t.Fatal("retired term discovery", e)
|
||||
}
|
||||
old, e := r.Revision(ctx, ref, 1)
|
||||
if e != nil || len(old.Terms) != 1 {
|
||||
t.Fatal("retirement rewrote history", e)
|
||||
}
|
||||
save(t, db, ref, 2, old, true)
|
||||
}
|
||||
func TestPaginationFiltersDraftsBeforeLimitAndRollback(t *testing.T) {
|
||||
db, r := fixture(t)
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 35; i++ {
|
||||
save(t, db, cms.Reference{Kind: "news", ID: fmt.Sprintf("news-%02d", i)}, 1, cms.Associations{Terms: []string{"go"}}, i >= 30)
|
||||
}
|
||||
var after *cms.Reference
|
||||
var ids []string
|
||||
for {
|
||||
p, e := r.Members(ctx, "go", after, 2)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for _, v := range p.Items {
|
||||
ids = append(ids, v.ID)
|
||||
}
|
||||
if p.Next == nil {
|
||||
break
|
||||
}
|
||||
after = p.Next
|
||||
}
|
||||
if len(ids) != 5 || ids[0] != "news-30" || ids[4] != "news-34" {
|
||||
t.Fatal(ids)
|
||||
}
|
||||
tx, e := db.BeginTx(ctx, nil)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
ref := cms.Reference{Kind: "project", ID: "rollback"}
|
||||
if e = PutRevision(ctx, tx, "merchant", ref, 1, cms.Associations{}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = SetPublished(ctx, tx, "merchant", ref, 1); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = tx.Rollback(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e = r.PublishedRevision(ctx, ref); !errors.Is(e, cms.ErrNotFound) {
|
||||
t.Fatal("partial transaction", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package cmssqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"gamertan.com/web/cms"
|
||||
)
|
||||
|
||||
func notFound(err error) error {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return cms.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Reader) Taxonomy(ctx context.Context, id string) (cms.Taxonomy, error) {
|
||||
var v cms.Taxonomy
|
||||
err := r.db.QueryRowContext(ctx, `SELECT id,slug,name,description,revision,active FROM gwf_cms_taxonomies WHERE scope=? AND id=?`, r.scope, id).Scan(&v.ID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active)
|
||||
return v, notFound(err)
|
||||
}
|
||||
func (r *Reader) Taxonomies(ctx context.Context) ([]cms.Taxonomy, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id,slug,name,description,revision,active FROM gwf_cms_taxonomies WHERE scope=? ORDER BY slug LIMIT 101`, r.scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
values := []cms.Taxonomy{}
|
||||
for rows.Next() {
|
||||
var v cms.Taxonomy
|
||||
if err := rows.Scan(&v.ID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, v)
|
||||
}
|
||||
if len(values) > 100 {
|
||||
return nil, cms.ErrInvalid
|
||||
}
|
||||
return values, rows.Err()
|
||||
}
|
||||
func PutTaxonomy(ctx context.Context, tx *sql.Tx, scope string, v cms.Taxonomy, expected int64) error {
|
||||
if !cms.ValidID(scope) || v.Validate() != nil || expected < 0 || v.Revision != expected+1 {
|
||||
return cms.ErrInvalid
|
||||
}
|
||||
var result sql.Result
|
||||
var err error
|
||||
if expected == 0 {
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_cms_taxonomies WHERE scope=?`, scope).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= 100 {
|
||||
return cms.ErrInvalid
|
||||
}
|
||||
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_taxonomies(scope,id,slug,name,description,revision,active) VALUES(?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, v.ID, v.Slug, v.Name, v.Description, v.Revision, v.Active)
|
||||
} else {
|
||||
result, err = tx.ExecContext(ctx, `UPDATE gwf_cms_taxonomies SET name=?,description=?,revision=?,active=? WHERE scope=? AND id=? AND revision=? AND slug=?`, v.Name, v.Description, v.Revision, v.Active, scope, v.ID, expected, v.Slug)
|
||||
}
|
||||
return oneRow(result, err)
|
||||
}
|
||||
func oneRow(result sql.Result, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 1 {
|
||||
return cms.ErrConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (r *Reader) Term(ctx context.Context, id string) (cms.Term, error) {
|
||||
var v cms.Term
|
||||
err := r.db.QueryRowContext(ctx, `SELECT id,taxonomy_id,slug,name,description,revision,active FROM gwf_cms_terms WHERE scope=? AND id=?`, r.scope, id).Scan(&v.ID, &v.TaxonomyID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active)
|
||||
return v, notFound(err)
|
||||
}
|
||||
|
||||
// Terms pages by immutable ID, including retired values for editors. The caller
|
||||
// filters public availability using both taxonomy and term Active fields.
|
||||
func (r *Reader) Terms(ctx context.Context, taxonomyID, after string, limit int) ([]cms.Term, error) {
|
||||
if !cms.ValidID(taxonomyID) || (after != "" && !cms.ValidID(after)) || limit < 1 || limit > 200 {
|
||||
return nil, cms.ErrInvalid
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT id,taxonomy_id,slug,name,description,revision,active FROM gwf_cms_terms WHERE scope=? AND taxonomy_id=? AND id>? ORDER BY id LIMIT ?`, r.scope, taxonomyID, after, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
values := []cms.Term{}
|
||||
for rows.Next() {
|
||||
var v cms.Term
|
||||
if err := rows.Scan(&v.ID, &v.TaxonomyID, &v.Slug, &v.Name, &v.Description, &v.Revision, &v.Active); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, v)
|
||||
}
|
||||
return values, rows.Err()
|
||||
}
|
||||
|
||||
// TermBySlug resolves old term slugs to the current record. The caller redirects
|
||||
// to v.Slug after checking visibility; no private/retired term is published here.
|
||||
func (r *Reader) TermBySlug(ctx context.Context, taxonomyID, slug string) (cms.Term, error) {
|
||||
var id string
|
||||
err := r.db.QueryRowContext(ctx, `SELECT id FROM gwf_cms_terms WHERE scope=? AND taxonomy_id=? AND slug=? UNION SELECT term_id FROM gwf_cms_term_slugs WHERE scope=? AND taxonomy_id=? AND slug=? LIMIT 1`, r.scope, taxonomyID, slug, r.scope, taxonomyID, slug).Scan(&id)
|
||||
if err != nil {
|
||||
return cms.Term{}, notFound(err)
|
||||
}
|
||||
return r.Term(ctx, id)
|
||||
}
|
||||
func PutTerm(ctx context.Context, tx *sql.Tx, scope string, v cms.Term, expected int64) error {
|
||||
if !cms.ValidID(scope) || v.Validate() != nil || expected < 0 || v.Revision != expected+1 {
|
||||
return cms.ErrInvalid
|
||||
}
|
||||
r, _ := New(tx, scope)
|
||||
tax, err := r.Taxonomy(ctx, v.TaxonomyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !tax.Active && v.Active {
|
||||
return cms.ErrInvalid
|
||||
}
|
||||
var old cms.Term
|
||||
if expected > 0 {
|
||||
old, err = r.Term(ctx, v.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if old.Revision != expected || old.TaxonomyID != v.TaxonomyID {
|
||||
return cms.ErrConflict
|
||||
}
|
||||
}
|
||||
occupied, err := r.TermBySlug(ctx, v.TaxonomyID, v.Slug)
|
||||
if err == nil && occupied.ID != v.ID {
|
||||
return cms.ErrConflict
|
||||
}
|
||||
if err != nil && !errors.Is(err, cms.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
var result sql.Result
|
||||
if expected == 0 {
|
||||
result, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_terms(scope,id,taxonomy_id,slug,name,description,revision,active) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, scope, v.ID, v.TaxonomyID, v.Slug, v.Name, v.Description, v.Revision, v.Active)
|
||||
} else {
|
||||
result, err = tx.ExecContext(ctx, `UPDATE gwf_cms_terms SET slug=?,name=?,description=?,revision=?,active=? WHERE scope=? AND id=? AND revision=?`, v.Slug, v.Name, v.Description, v.Revision, v.Active, scope, v.ID, expected)
|
||||
}
|
||||
if err = oneRow(result, err); err != nil {
|
||||
return err
|
||||
}
|
||||
if expected > 0 && old.Slug != v.Slug {
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_cms_term_slugs WHERE scope=? AND taxonomy_id=? AND slug=? AND term_id=?`, scope, v.TaxonomyID, v.Slug, v.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_cms_term_slugs(scope,taxonomy_id,slug,term_id) VALUES(?,?,?,?)`, scope, v.TaxonomyID, old.Slug, v.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -28,6 +28,8 @@
|
||||
// optional no-CGO SQLite adapter.
|
||||
// - [organizations] and [access] model organizations, teams, invitations,
|
||||
// scoped roles, and audited temporary access.
|
||||
// - [cms] and [cmssqlite] add revision-aware taxonomies and editorial links
|
||||
// alongside application-owned content and coded templates.
|
||||
// - [abuse] applies application-classified request-abuse decisions.
|
||||
// - [analytics] creates bounded, disposable projections from requestlog
|
||||
// records without becoming a telemetry service.
|
||||
@@ -58,6 +60,8 @@
|
||||
// [authhttp]: https://pkg.go.dev/gamertan.com/web/authhttp
|
||||
// [authsqlite]: https://pkg.go.dev/gamertan.com/web/authsqlite
|
||||
// [authwebauthn]: https://pkg.go.dev/gamertan.com/web/authwebauthn
|
||||
// [cms]: https://pkg.go.dev/gamertan.com/web/cms
|
||||
// [cmssqlite]: https://pkg.go.dev/gamertan.com/web/cmssqlite
|
||||
// [organizations]: https://pkg.go.dev/gamertan.com/web/organizations
|
||||
// [requestlog]: https://pkg.go.dev/gamertan.com/web/requestlog
|
||||
// [requestmeta]: https://pkg.go.dev/gamertan.com/web/requestmeta
|
||||
|
||||
@@ -21,3 +21,26 @@ template. Its private evidence, persistent bans, account data, route policy,
|
||||
operator exclusions, synchronization, and publishing workflow remain
|
||||
application-owned. Useful pressure from that migration may improve a general
|
||||
interface, but it may not smuggle EQL-specific policy into this module.
|
||||
|
||||
## Optional personal-profile editing
|
||||
|
||||
`auth.OwnProfileRepository` supports a narrow self-service boundary independently
|
||||
of instance-directory authorization. Load the profile using the current session
|
||||
digest; derive the target from that result. Normalize one username or display
|
||||
name using `auth.NormalizeProfileValue`. Never decode an HTTP body directly into
|
||||
`auth.ProfileEdit`, which carries trusted identity and credential-check state.
|
||||
|
||||
Require CSRF/origin validation for browser writes and rate-limit credential work.
|
||||
For username changes, verify the current password (supply its hash as
|
||||
`ExpectedPasswordHash`) or consume an exact operation-bound passkey approval;
|
||||
enforce any additional authentication policy your application requires. Include
|
||||
the session, user, value and expected profile revision in the passkey binding.
|
||||
The SQLite transaction rechecks session/account/revision and any verified hash,
|
||||
updates one field, revokes other sessions for username edits, and appends audit.
|
||||
Do not log the command or include secret material in its audit.
|
||||
|
||||
Schema 11 adds `profile_revision` without changing stable identity keys. Run an
|
||||
explicit migration before starting an adopter with automatic migration disabled.
|
||||
Keep the pre-migration backup; adjacent older binaries are not approved writers
|
||||
for the migrated schema. Email-change enrollment/confirmation is not implemented
|
||||
by this interface and must not be simulated with an unverified direct update.
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<!-- SPDX-License-Identifier: MPL-2.0 -->
|
||||
|
||||
# Classification without a page builder
|
||||
|
||||
`cms` defines small editorial values. `cmssqlite` stores them in caller-owned
|
||||
SQLite transactions. Neither package owns your content, router, templates,
|
||||
permissions or billing model. Use them alongside typed Go records and coded
|
||||
templates, not as a database-defined application builder.
|
||||
|
||||
## Two distinct relationships
|
||||
|
||||
- A **taxonomy** names a flat vocabulary, such as Categories or Topics. A **term**
|
||||
has a stable ID, editable name/description and an address. Renaming its address
|
||||
preserves aliases; retiring it hides discovery without rewriting old revisions.
|
||||
- An **explicit link** joins two `{kind, id}` references. `Related` reads that
|
||||
single edge in either direction. Sharing a term alone does not assert a link.
|
||||
|
||||
References contain no titles, URLs, application data or authorization. Resolve
|
||||
each public result through the owning repository using its current published
|
||||
revision and availability. Never render the latest draft merely because an older
|
||||
revision is published. Products may have additional availability/approval rules;
|
||||
editorial links do not bypass them or change prices, entitlements or purchases.
|
||||
|
||||
## Transactions and publication
|
||||
|
||||
Call `CreateSchema(ctx, tx)` during an explicit application migration. It creates
|
||||
the `gwf_cms_*` tables, independently of the authentication adapter's schema.
|
||||
There is no automatic migration, connection, worker or request middleware.
|
||||
Enable foreign keys on every connection and use the application's existing
|
||||
SQLite write discipline. Keep backups and schema compatibility in that owner.
|
||||
|
||||
Every reader/writer takes a validated namespace. A namespace separates data; it
|
||||
does not authorize the caller. Check permissions before reads and inside the
|
||||
application's mutation boundary where concurrent revocation matters.
|
||||
|
||||
Within the same transaction as your content revision and audit:
|
||||
|
||||
1. `PutRevision` stores the exact revision's immutable term/link selections.
|
||||
2. For publication, `SetPublished` points at that revision. Zero unpublishes.
|
||||
3. Commit content, associations, publication and audit together.
|
||||
|
||||
Saving a draft does not advance publication. Restore by copying the selected
|
||||
historical association document into a new revision, then publish separately.
|
||||
Terms must exist in the namespace; retired terms remain valid historical values.
|
||||
Applications decide which retired selections may be retained in new edits.
|
||||
External targets can be indexed with an empty published snapshot, but their
|
||||
owning module still determines whether a public link is available.
|
||||
|
||||
Taxonomy/term writes use expected revisions (zero for creation). Keep immutable
|
||||
IDs across name changes. Taxonomy addresses are fixed after creation; term
|
||||
addresses retain redirect history. A collision or stale revision returns
|
||||
`cms.ErrConflict`, not a successful overwrite. Transactions must be rolled back
|
||||
after any mutation error, including a later application audit failure.
|
||||
|
||||
## Bounds and discovery
|
||||
|
||||
- Each snapshot accepts at most 24 distinct terms and 16 distinct links, without
|
||||
self-links. Validation rejects invalid IDs, control characters and duplicate
|
||||
selections. Text fields have explicit byte bounds; names are not HTML.
|
||||
- A namespace has at most 100 taxonomies. `Terms` pages by stable term ID, at
|
||||
most 200 results per request. Applications should choose their own overall
|
||||
editor limits and search UI rather than loading an unbounded catalog.
|
||||
- `Members` and `Related` return at most 100 published references per page,
|
||||
ordered by kind/ID. Pass `Next` for the following page. Publication filtering
|
||||
happens before pagination; never use a mutable title as a cursor.
|
||||
- Owning-module visibility can filter further. Continue fetching bounded index
|
||||
pages to fill a visible page and construct a cursor from the last visible
|
||||
item; do not expose private titles or identifiers through error messages.
|
||||
|
||||
The package tests use real SQLite transactions, including WAL, race execution,
|
||||
scope isolation, delayed publication, reverse discovery, aliases, retirement,
|
||||
pagination and rollback. Consumer tests still need to prove actual HTTP/API
|
||||
permissions, public visibility, escaping, editor usability and application data
|
||||
preservation. These packages do not claim to provide an entire CMS.
|
||||
+30
-2
@@ -8,6 +8,34 @@ application concern belongs in the shared module.
|
||||
|
||||
## Gamertan accounts and commerce
|
||||
|
||||
- Typed content needs shared categories and relationships without becoming a
|
||||
page builder. The `cms`/`cmssqlite` boundary separates application-owned bodies
|
||||
and products from immutable editorial associations. Keeping associations and
|
||||
publication in the content transaction prevents a draft save from changing
|
||||
public reverse links. Stable references avoid rewriting purchases on a project
|
||||
rename. Consumer HTTP tests caught compiled article snapshots shadowing CMS
|
||||
revisions and catalog pickers showing newer draft titles; those are application
|
||||
routing/visibility responsibilities, not extra policy in this package.
|
||||
|
||||
- Personal identity editing is not instance administration. `OwnProfileRepository`
|
||||
derives self-access from the active session; `ProfileEdit` is a trusted internal
|
||||
command, never a browser request model. SQLite schema 11 adds a monotonic
|
||||
revision because timestamps alone cannot distinguish two edits in one second.
|
||||
Session/account/revision checks, mutation and audit share one write transaction.
|
||||
Username edits invalidate other sessions without changing immutable IDs,
|
||||
memberships, credentials, orders or provider billing identities. A password
|
||||
proof binds the verified hash into that transaction; passkey proofs must bind
|
||||
the exact user/session/field/value/revision before calling it. The application
|
||||
chooses account-specific reauthentication and owns its credential-work limits.
|
||||
Email requires a separate verified change protocol, not another accepted field.
|
||||
|
||||
- Instance operators need all-user/all-organization directories, not a staff
|
||||
roster or implicit membership in every business. Optional bounded readers now
|
||||
expose identity/profile records without credentials, independent of membership.
|
||||
The application must authorize each call through an explicit instance scope;
|
||||
these readers intentionally contain no Gamertan-specific roles or UI policy.
|
||||
Stable-ID cursors and literal searches are covered against pagination gaps,
|
||||
renamed profiles, inactive/personal records and wildcard/query injection.
|
||||
- Customer profile and membership editing requires current ownership for every
|
||||
write, not just changes involving another owner. The existing generic methods
|
||||
intentionally permit application-authorized delegated administrators, so an
|
||||
@@ -40,8 +68,8 @@ application concern belongs in the shared module.
|
||||
policy; there is no new database schema or commerce dependency in Foundations.
|
||||
|
||||
- The account email remains required and unique. Gamertan uses normalized
|
||||
email as the canonical login identifier and keeps username as a stable public
|
||||
identity. Until a mail package exists, the application must not describe an
|
||||
email as the canonical login identifier; the immutable user ID, not the editable
|
||||
username, owns account relationships. Until a mail package exists, it must not describe an
|
||||
address as verified merely because it was entered during registration.
|
||||
- Password authentication is sufficient for an ordinary customer base
|
||||
session. Privileged application actions use an exact operation binding with
|
||||
|
||||
@@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
|
||||
module checksum:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.24
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.27
|
||||
go mod verify
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
|
||||
and request the containing module at an exact version:
|
||||
|
||||
```bash
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.24
|
||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.25
|
||||
```
|
||||
|
||||
Only imported packages are compiled and linked. The packages nevertheless
|
||||
|
||||
@@ -40,6 +40,25 @@ never accept the owner-role policy or actor identity from submitted fields.
|
||||
|
||||
## Creating an organization with an owner
|
||||
|
||||
For instance-wide administrative directories, the optional
|
||||
`auth.UserDirectoryRepository` and `organizations.DirectoryRepository` readers
|
||||
on `authsqlite.Store` list all identities/organizations, not just memberships.
|
||||
**Authorize an explicit instance-read capability before every call.** These are
|
||||
not customer self-service or public directory APIs; they deliberately include
|
||||
incomplete/inactive accounts and personal/archived organizations without exposing
|
||||
credentials, recovery material or invitations. Merchant classification remains
|
||||
application policy. Reading never creates a membership or grants a role.
|
||||
|
||||
Both queries accept literal `Search` (up to 128 bytes), exclusive `AfterID`, and
|
||||
`Limit` (default 50, maximum 200). An empty `NextID` ends the result. Preserve the
|
||||
search when following a cursor; reset it when changing the search. IDs give stable
|
||||
ordering despite renamed profiles, but pages are current views rather than a
|
||||
multi-request snapshot. New records sorting before a cursor appear on a fresh
|
||||
listing. SQLite search folds ASCII case; non-ASCII display-name text matches with
|
||||
its original case. Wildcards and SQL fragments are always literal search text.
|
||||
These optional readers do not change the required authentication/organization
|
||||
repository contracts or schema 10.
|
||||
|
||||
For an existing authenticated user creating a business, use
|
||||
`CreateOwnedOrganization` with `OwnerRole` configured when constructing the
|
||||
service. Seed that role first. This commits the organization, active membership,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package organizations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var ErrDirectoryQuery = errors.New("organizations: invalid directory query")
|
||||
|
||||
// DirectoryQuery searches all organizations independently of membership.
|
||||
// Search is literal text. AfterID is an exclusive stable-ID cursor. Limit
|
||||
// defaults to 50 and may not exceed 200.
|
||||
type DirectoryQuery struct {
|
||||
Search, AfterID string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type DirectoryPage struct {
|
||||
Organizations []Organization
|
||||
NextID string
|
||||
}
|
||||
|
||||
// DirectoryRepository is an optional administrative read capability. The caller
|
||||
// MUST authorize instance-wide organization access. Personal and archived records
|
||||
// are included; no membership is granted and no invitations or secrets are read.
|
||||
// The application classifies its configured merchant organization. Pagination is
|
||||
// a current view, not a snapshot across requests.
|
||||
type DirectoryRepository interface {
|
||||
OrganizationDirectory(context.Context, DirectoryQuery) (DirectoryPage, error)
|
||||
}
|
||||
@@ -29,6 +29,9 @@ analytics/fuzz_test.go
|
||||
analytics/geo.go
|
||||
auth/auth.go
|
||||
auth/context.go
|
||||
auth/directory.go
|
||||
auth/profile.go
|
||||
auth/profile_test.go
|
||||
auth/password.go
|
||||
auth/password_test.go
|
||||
authrecovery/recovery.go
|
||||
@@ -41,6 +44,10 @@ authhttp/passkey.go
|
||||
authhttp/passkey_test.go
|
||||
authsqlite/store.go
|
||||
authsqlite/store_test.go
|
||||
authsqlite/directory.go
|
||||
authsqlite/directory_test.go
|
||||
authsqlite/profile.go
|
||||
authsqlite/profile_test.go
|
||||
authsqlite/account.go
|
||||
authsqlite/account_test.go
|
||||
authsqlite/access.go
|
||||
@@ -61,6 +68,13 @@ authwebauthn/service_test.go
|
||||
authwebauthn/types.go
|
||||
bootstrap/bootstrap.go
|
||||
bootstrap/bootstrap_test.go
|
||||
cms/cms.go
|
||||
cms/cms_test.go
|
||||
cmssqlite/schema.go
|
||||
cmssqlite/terms.go
|
||||
cmssqlite/associations.go
|
||||
cmssqlite/store_test.go
|
||||
cmssqlite/example_test.go
|
||||
media/media.go
|
||||
media/media_test.go
|
||||
medialocal/store.go
|
||||
@@ -69,6 +83,7 @@ internal/webauthnvendored/
|
||||
docs/ADOPTION.md
|
||||
docs/ARCHITECTURE.md
|
||||
docs/DEPENDENCIES.md
|
||||
docs/CMS.md
|
||||
docs/DOGFOOD.md
|
||||
docs/GETTING_STARTED.md
|
||||
docs/MODULES.md
|
||||
@@ -91,6 +106,7 @@ requestmeta/requestmeta_test.go
|
||||
organizations/organizations.go
|
||||
organizations/organizations_test.go
|
||||
organizations/owned.go
|
||||
organizations/directory.go
|
||||
organizations/owned_test.go
|
||||
organizations/role_invitations.go
|
||||
organizations/role_invitations_test.go
|
||||
|
||||
Reference in New Issue
Block a user