mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
feat(qrypt): qrypt.chat anonymous-invite issuer
AgentBBS becomes a trusted Ed25519 issuer for qrypt.chat anonymous accounts. Members mint a signed, single-use qci1 token (per the shared invite contract) that qrypt.chat verifies and redeems. - internal/qryptinvite: Mint / GenerateIssuerKey / ParsePrivateKey + Config (AGENTBBS_QRYPT_* env) with unit tests (independent verify, payload assertions, jti uniqueness, tamper rejection, seed/full key). - store: qrypt_invites table + QryptInviteCount / RecordQryptInvite (per-member quota, enforced in a tx; ErrQuotaExceeded) + test. - plugins/qryptinvite: hub plugin (members only) — checks quota, mints, records, prints token + redeem URL. - cmd/agentbbs: `qrypt-invite <user>` and `qrypt-issuer-keygen` subcommands wired into dispatch. - setup.sh env template + docs/qrypt-invites.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8adafaf515
commit
97da723c5c
10 changed files with 855 additions and 1 deletions
|
|
@ -152,6 +152,15 @@ type Store interface {
|
|||
// UserByToken resolves an API token to its account name.
|
||||
UserByToken(token string) (string, bool, error)
|
||||
|
||||
// qrypt.chat anonymous-invite issuance (docs/qrypt-invites.md).
|
||||
|
||||
// QryptInviteCount reports how many qrypt.chat invites username has issued.
|
||||
QryptInviteCount(username string) (int, error)
|
||||
// RecordQryptInvite records one issued invite (its jti, for audit and as
|
||||
// the per-member quota counter) against username. It returns
|
||||
// ErrQuotaExceeded if the member is already at or above quota.
|
||||
RecordQryptInvite(username, jti string, quota int) error
|
||||
|
||||
Close() error
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +235,9 @@ var ErrKeyMismatch = errors.New("username registered with a different key")
|
|||
// ErrDomainTaken means a domain is already mapped to a different member.
|
||||
var ErrDomainTaken = errors.New("domain already mapped to another account")
|
||||
|
||||
// ErrQuotaExceeded means a member has hit their qrypt.chat invite quota.
|
||||
var ErrQuotaExceeded = errors.New("qrypt invite quota exceeded")
|
||||
|
||||
type sqliteStore struct{ db *sql.DB }
|
||||
|
||||
// Open opens (and migrates) the SQLite store at path.
|
||||
|
|
@ -381,6 +393,12 @@ CREATE TABLE IF NOT EXISTS api_tokens (
|
|||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(username);
|
||||
CREATE TABLE IF NOT EXISTS qrypt_invites (
|
||||
jti TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_qrypt_invites_user ON qrypt_invites(username);
|
||||
`
|
||||
|
||||
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
||||
|
|
@ -807,4 +825,36 @@ func (s *sqliteStore) SetPluginDisabled(id string, disabled bool) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) QryptInviteCount(username string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM qrypt_invites WHERE username = ?`, username).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// RecordQryptInvite atomically enforces the per-member quota and records the
|
||||
// invite's jti. The count check and the insert run in one transaction so two
|
||||
// concurrent issuances can't both slip past the cap.
|
||||
func (s *sqliteStore) RecordQryptInvite(username, jti string, quota int) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var n int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM qrypt_invites WHERE username = ?`, username).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if quota > 0 && n >= quota {
|
||||
return ErrQuotaExceeded
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO qrypt_invites (jti, username) VALUES (?,?)`, jti, username); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) Close() error { return s.db.Close() }
|
||||
|
|
|
|||
52
internal/store/store_qrypt_test.go
Normal file
52
internal/store/store_qrypt_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQryptInviteQuota(t *testing.T) {
|
||||
st, err := Open(filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
const quota = 3
|
||||
|
||||
if n, err := st.QryptInviteCount("alice"); err != nil || n != 0 {
|
||||
t.Fatalf("initial count = %d, %v; want 0, nil", n, err)
|
||||
}
|
||||
|
||||
for i := 0; i < quota; i++ {
|
||||
jti := "jti-alice-" + string(rune('a'+i))
|
||||
if err := st.RecordQryptInvite("alice", jti, quota); err != nil {
|
||||
t.Fatalf("record %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if n, err := st.QryptInviteCount("alice"); err != nil || n != quota {
|
||||
t.Fatalf("count after fill = %d, %v; want %d", n, err, quota)
|
||||
}
|
||||
|
||||
// One over the cap must be rejected with ErrQuotaExceeded and not stored.
|
||||
if err := st.RecordQryptInvite("alice", "jti-over", quota); !errors.Is(err, ErrQuotaExceeded) {
|
||||
t.Fatalf("over-quota err = %v; want ErrQuotaExceeded", err)
|
||||
}
|
||||
if n, _ := st.QryptInviteCount("alice"); n != quota {
|
||||
t.Fatalf("count after rejected insert = %d; want %d", n, quota)
|
||||
}
|
||||
|
||||
// Quotas are per-member: bob is unaffected.
|
||||
if err := st.RecordQryptInvite("bob", "jti-bob", quota); err != nil {
|
||||
t.Fatalf("bob record: %v", err)
|
||||
}
|
||||
if n, _ := st.QryptInviteCount("bob"); n != 1 {
|
||||
t.Fatalf("bob count = %d; want 1", n)
|
||||
}
|
||||
|
||||
// quota <= 0 means unlimited.
|
||||
if err := st.RecordQryptInvite("carol", "jti-carol-1", 0); err != nil {
|
||||
t.Fatalf("unlimited record: %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue