Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -674,8 +674,12 @@ jobs:
with:
persist-credentials: false
repository: HitKeep/hitkeep-assistant-plugins
ref: dbe2227280b43d23c0b253f8796c10eb120f82c5
ref: 4f2c2f8d4bc917629bbfaf61748c6636b04c88d1
path: packager
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: app/go.mod
cache-dependency-path: app/go.sum
- name: Generate and verify one canonical assistant package
env:
VERSION: ${{ inputs.version }}
Expand All @@ -686,7 +690,7 @@ jobs:
[[ "$VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ && "$SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]]
test "$(git -C app rev-parse HEAD)" = "$SOURCE_SHA"
test "$(jq -r .version app/frontend/dashboard/package.json)" = "${VERSION#v}"
test "$(git -C packager rev-parse HEAD)" = dbe2227280b43d23c0b253f8796c10eb120f82c5
test "$(git -C packager rev-parse HEAD)" = 4f2c2f8d4bc917629bbfaf61748c6636b04c88d1
python3 packager/scripts/package.py --sync-from app --source-sha "$SOURCE_SHA" --version "${VERSION#v}"
python3 packager/scripts/package.py --verify-source app --source-sha "$SOURCE_SHA"
mkdir "$RUNNER_TEMP/assistant-package"
Expand Down
1 change: 1 addition & 0 deletions api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,7 @@ type PermissionContext struct {
}

type SystemStatus struct {
AIEnabled bool `json:"ai_enabled"`
NeedsSetup bool `json:"needs_setup"`
Version string `json:"version"`
Cloud *CloudStatus `json:"cloud,omitempty"`
Expand Down
27 changes: 17 additions & 10 deletions auth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,26 @@ func AuthorizeOAuth(grant *OAuthGrant, principal Principal, action Capability, r
return false
}
// A protocol scope cannot be used as a broader application permission.
switch scope {
case "sites:read", "analytics:read", "tracking_domains:read":
if action != Capability(PermSiteView) {
return false
}
case "ingest:write":
if action != Capability(PermSiteManageData) {
return false
}
default:
if permission := OAuthScopePermission(scope); permission == "" || action != Capability(permission) {
return false
}
principal.Kind = PrincipalOAuth
principal.InstanceRole = ""
return Authorize(principal, action, resource)
}

// OAuthScopePermission is the permission ceiling shared by consent and execution.
func OAuthScopePermission(scope string) Permission {
switch scope {
case "sites:read", "analytics:read", "tracking_domains:read":
return PermSiteView
case "ingest:write", "sites:write", "opportunities:write":
return PermSiteManageData
case "goals:write":
return PermSiteManageGoals
case "annotations:write":
return PermSiteManageAnnotations
default:
return ""
}
}
38 changes: 38 additions & 0 deletions auth/oauth_actions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package auth

import (
"testing"
"uuid"
)

func TestOAuthActionScopesCannotEscalate(t *testing.T) {
site, team := uuid.NewV7(), uuid.NewV7()
for _, tc := range []struct {
scope string
permission Permission
}{
{"goals:write", PermSiteManageGoals},
{"annotations:write", PermSiteManageAnnotations},
{"sites:write", PermSiteManageData},
{"opportunities:write", PermSiteManageData},
} {
t.Run(tc.scope, func(t *testing.T) {
grant := &OAuthGrant{ID: uuid.NewV7(), UserID: uuid.NewV7(), TeamID: team, SiteIDs: []uuid.UUID{site}, Scopes: []string{tc.scope}, Audience: "https://example.test/api"}
principal := Principal{Kind: PrincipalOAuth, SiteID: site, TeamID: team, SiteRole: SiteAdmin}
resource := Resource{Kind: ResourceSite, ID: site, TeamID: team}
if !AuthorizeOAuth(grant, principal, Capability(tc.permission), resource, grant.Audience, tc.scope) {
t.Fatal("explicit scoped action rejected")
}
for _, forbidden := range []Permission{PermSiteManageTeam, PermSiteManageWebhooks, PermSiteDelete, PermInstanceManageUsers} {
if AuthorizeOAuth(grant, principal, Capability(forbidden), resource, grant.Audience, tc.scope) {
t.Fatalf("scope escalated to %s", forbidden)
}
}
principal.SiteRole = SiteViewer
principal.InstanceRole = InstanceOwner
if AuthorizeOAuth(grant, principal, Capability(tc.permission), resource, grant.Audience, tc.scope) {
t.Fatal("instance owner bypassed delegated current site role")
}
})
}
}
68 changes: 68 additions & 0 deletions cmd/skillpack/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Command skillpack writes the same public skill artifacts served by HitKeep.
package main

import (
"encoding/json/v2"
"flag"
"fmt"
"net/url"
"os"
"strings"

"hitkeep/skills"
)

func main() {
output := flag.String("output", "", "new output directory")
base := flag.String("base-url", "", "absolute HTTPS directory containing the released archives")
flag.Parse()
if flag.NArg() != 0 {
fmt.Fprintln(os.Stderr, "unexpected positional arguments")
os.Exit(2)
}
if err := run(*output, *base); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

func run(output, base string) error {
u, err := url.Parse(base)
if output == "" || err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
return fmt.Errorf("output and an absolute HTTPS base URL without credentials, query or fragment are required")
}
base = strings.TrimRight(base, "/")
index, err := skills.PublicIndex(base)
if err != nil {
return err
}
artifacts := make(map[string][]byte, len(index.Skills)+1)
for i, skill := range index.Skills {
raw, err := skills.PublicArchive(skill.Name)
if err != nil {
return err
}
artifacts[skill.Name+".zip"] = raw
index.Skills[i].URL = base + "/" + skill.Name + ".zip"
}
raw, err := json.Marshal(index)
if err != nil {
return err
}
artifacts["index.json"] = append(raw, '\n')
// Refuse existing output rather than overwriting a previous release or following a symlink.
if err := os.Mkdir(output, 0o755); err != nil {
return fmt.Errorf("create new output directory: %w", err)
}
root, err := os.OpenRoot(output)
if err != nil {
return err
}
defer root.Close()
for name, raw := range artifacts {
if err := root.WriteFile(name, raw, 0o644); err != nil {
return fmt.Errorf("write %s: %w", name, err)
}
}
return nil
}
75 changes: 75 additions & 0 deletions cmd/skillpack/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package main

import (
"bytes"
"crypto/sha256"
"encoding/json/v2"
"fmt"
"os"
"path/filepath"
"testing"

"hitkeep/skills"
)

func TestArtifactsMatchRuntime(t *testing.T) {
first, second := filepath.Join(t.TempDir(), "first"), filepath.Join(t.TempDir(), "second")
for _, output := range []string{first, second} {
if err := run(output, "https://hitkeep.com/skills/v2.16.0"); err != nil {
t.Fatal(err)
}
}
var index skills.DiscoveryIndex
raw, err := os.ReadFile(filepath.Join(first, "index.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &index); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(first)
if err != nil {
t.Fatal(err)
}
if len(index.Skills) != 5 || len(entries) != 6 {
t.Fatalf("inventory: %d skills, %d files", len(index.Skills), len(entries))
}
for _, skill := range index.Skills {
expected, err := skills.PublicArchive(skill.Name)
if err != nil {
t.Fatal(err)
}
actual, err := os.ReadFile(filepath.Join(first, skill.Name+".zip"))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(actual, expected) || skill.Digest != fmt.Sprintf("sha256:%x", sha256.Sum256(actual)) {
t.Fatal("archive differs from runtime")
}
if skill.URL != "https://hitkeep.com/skills/v2.16.0/"+skill.Name+".zip" {
t.Fatalf("URL: %s", skill.URL)
}
}
for _, entry := range entries {
a, _ := os.ReadFile(filepath.Join(first, entry.Name()))
b, _ := os.ReadFile(filepath.Join(second, entry.Name()))
if !bytes.Equal(a, b) {
t.Fatalf("non-deterministic %s", entry.Name())
}
}
if err := run(first, "https://hitkeep.com/skills/v2.16.0"); err == nil {
t.Fatal("overwrote existing output")
}
}

func TestRejectInvalidBaseBeforeWriting(t *testing.T) {
for _, base := range []string{"", "http://example.test", "https://user:password@example.test", "https://example.test?query", "https://example.test?", "https://example.test/#fragment"} {
output := filepath.Join(t.TempDir(), "output")
if err := run(output, base); err == nil {
t.Fatalf("accepted %s", base)
}
if _, err := os.Stat(output); !os.IsNotExist(err) {
t.Fatal("invalid input wrote output")
}
}
}
13 changes: 13 additions & 0 deletions database/store_cloud_billing_billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,19 @@ func (s *Store) UpsertCloudBillingAccount(ctx context.Context, account CloudBill
)
}

// MarkCloudBillingCheckoutPending preserves a webhook's authoritative state if it changed during Checkout.
func (s *Store) MarkCloudBillingCheckoutPending(ctx context.Context, previous CloudBillingAccount, billingInterval, priceID string) error {
return s.Exec(ctx, `
UPDATE cloud_billing_accounts
SET plan_code = ?, plan_name = ?, billing_interval = ?, subscription_status = ?,
stripe_subscription_id = NULL, stripe_price_id = ?, updated_at = ?
WHERE tenant_id = ? AND plan_code = ? AND subscription_status = ?
AND COALESCE(stripe_subscription_id, '') = ? AND COALESCE(stripe_customer_id, '') = ?
`, CloudPlanFree, "Free", normalizeCloudBillingInterval(billingInterval), CloudSubscriptionStatusPendingCheckout,
nullIfBlank(priceID), time.Now().UTC(), previous.TenantID, previous.PlanCode, previous.SubscriptionStatus,
previous.StripeSubscriptionID, previous.StripeCustomerID)
}

func normalizeCloudBillingInterval(interval string) string {
if strings.EqualFold(strings.TrimSpace(interval), CloudBillingIntervalAnnual) {
return CloudBillingIntervalAnnual
Expand Down
6 changes: 4 additions & 2 deletions database/store_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ func (o *OAuthTransaction) CreateGrant(ctx context.Context, g OAuthGrant) error
if err := o.pruneExpiredGrants(ctx); err != nil {
return err
}
if g.ID == uuid.Nil() || g.UserID == uuid.Nil() || g.TeamID == uuid.Nil() || len(g.SiteIDs) == 0 || len(g.SiteIDs) > 512 || len(g.Scopes) == 0 || len(g.Scopes) > 4 || g.ClientID == "" || g.ClientName == "" || g.Audience == "" || !g.ExpiresAt.After(g.CreatedAt) {
teamOnly := len(g.Scopes) == 1 && g.Scopes[0] == "billing:write"
if g.ID == uuid.Nil() || g.UserID == uuid.Nil() || g.TeamID == uuid.Nil() || (!teamOnly && len(g.SiteIDs) == 0) || len(g.SiteIDs) > 512 || len(g.Scopes) == 0 || len(g.Scopes) > 9 || g.ClientID == "" || g.ClientName == "" || g.Audience == "" || !g.ExpiresAt.After(g.CreatedAt) {
return errors.New("invalid OAuth grant")
}
scopes, err := json.Marshal(g.Scopes)
Expand Down Expand Up @@ -191,7 +192,8 @@ func (o *OAuthTransaction) GetGrant(ctx context.Context, id uuid.UUID) (*OAuthGr
if err := rows.Err(); err != nil {
return nil, oauthStoreError(err)
}
g.Active = g.Active && g.RevokedAt == nil && time.Now().UTC().Before(g.ExpiresAt) && len(g.SiteIDs) > 0
teamOnly := len(g.Scopes) == 1 && g.Scopes[0] == "billing:write"
g.Active = g.Active && g.RevokedAt == nil && time.Now().UTC().Before(g.ExpiresAt) && (len(g.SiteIDs) > 0 || teamOnly)
return &g, nil
}

Expand Down
29 changes: 25 additions & 4 deletions frontend/dashboard/public/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"oauth": {
"connectionDetails": "Verbindungsdetails",
"consentTitle": "Anwendung autorisieren",
"consentDescription": "Prüfe die Anwendung und wähle die Websites aus, auf die sie zugreifen darf.",
"consentDescription": "Prüfen Sie die Anwendung, die angeforderten Berechtigungen und das Team, auf das sie zugreifen darf.",
"loading": "Anwendungen und Berechtigungen werden geladen…",
"retry": "Erneut versuchen",
"application": "Anwendung",
Expand All @@ -43,7 +43,7 @@
"siteHelp": "Wähle jede Website einzeln aus. Neue Websites werden nicht automatisch einbezogen.",
"noEligibleSites": "Mit deinen aktuellen Berechtigungen sind keine Websites für diese Autorisierung verfügbar.",
"permissionNotice": "Die Anwendung erhält nur den ausgewählten Zugriff. Du kannst ihn unter Verbundene Anwendungen widerrufen.",
"approve": "Ausgewählte Websites autorisieren",
"approve": "Ausgewählten Zugriff autorisieren",
"deny": "Ablehnen und abbrechen",
"submitting": "Deine Entscheidung wird übermittelt…",
"cancel": "Zurück zu HitKeep",
Expand All @@ -64,7 +64,12 @@
"sitesRead": "Ausgewählte Websites auflisten",
"analyticsRead": "Analytics für ausgewählte Websites lesen",
"trackingDomainsRead": "Tracking-Domains ausgewählter Websites lesen",
"ingestWrite": "Analytics-Ereignisse an ausgewählte Websites senden"
"ingestWrite": "Analytics-Ereignisse an ausgewählte Websites senden",
"goalsWrite": "Ziele und Trichter für ausgewählte Websites verwalten",
"annotationsWrite": "Anmerkungen für ausgewählte Websites verwalten",
"sitesWrite": "Domains und Aufbewahrung für ausgewählte Websites ändern",
"opportunitiesWrite": "Chancen für ausgewählte Websites verwalten",
"billingWrite": "Checkout für das ausgewählte Team starten, prüfen und abbrechen; Zahlungen erfordern Ihre Bestätigung"
},
"signedInAs": "Angemeldet als {{ email }}",
"wrongAccount": "Um ein anderes Konto zu verwenden, melde dich über das Kontomenü ab.",
Expand All @@ -74,7 +79,9 @@
"continueAuthorization": "Zur Autorisierung fortfahren",
"resumeDescription": "Die Autorisierung deiner Anwendung steht noch aus. Fahre fort, um den angeforderten Zugriff zu prüfen.",
"checkoutWaiting": "Der Checkout ist noch nicht bestätigt. Schließe den bestehenden Zahlungsvorgang ab oder versuche es erneut.",
"resumeFailed": "Die ausstehende Autorisierung konnte nicht wiederhergestellt werden. Deine Abrechnungseinstellungen sind weiterhin verfügbar. Versuche es erneut."
"resumeFailed": "Die ausstehende Autorisierung konnte nicht wiederhergestellt werden. Deine Abrechnungseinstellungen sind weiterhin verfügbar. Versuche es erneut.",
"noEligibleTeams": "Mit Ihren aktuellen Berechtigungen stehen keine Teams für diese Autorisierung zur Verfügung.",
"billingHelp": "Dieser Zugriff gilt für das ausgewählte Team. Zahlungen erfordern weiterhin Ihre Bestätigung in Stripe Checkout."
},
"nav": {
"resizeSidebar": "Seitenleiste vergrößern oder verkleinern",
Expand Down Expand Up @@ -4080,5 +4087,19 @@
"invalid": "Gib einen Namen und mindestens eine HTTPS-Weiterleitungs-URI innerhalb der Feldgrenzen ein.",
"revokeTitle": "OAuth-Client widerrufen",
"revokeConfirm": "{{application}} widerrufen? Dies verhindert neue Autorisierungen und widerruft alle bestehenden Freigaben."
},
"agentTools": {
"listTitle": "Verfügbare HitKeep-Aktionen",
"listDescription": "Dokumentierte HitKeep-API-Aktionen für diese angemeldete Sitzung anzeigen. Zugangsdaten und Exporte bleiben in der normalen Oberfläche.",
"runTitle": "HitKeep-Aktion ausführen",
"runDescription": "Eine verfügbare API-Aktion mit den aktuellen Berechtigungen ausführen. Jede Änderung erfordert eine ausdrückliche Bestätigung.",
"confirmTitle": "Agentenaktion bestätigen",
"confirmAction": "Diese Agentenaktion für das Team {{team}} zulassen?\n\n{{action}}\n\n{{details}}",
"unavailable": "Agentenaktionen sind in der aktuellen Sitzung oder im aktuellen Kontext nicht verfügbar.",
"invalidAction": "Diese Aktion oder ihre Parameter sind für Agenten nicht verfügbar.",
"notConfirmed": "Die Aktion wurde nicht bestätigt.",
"confirmPending": "Eine andere Agentenaktion wartet auf Bestätigung.",
"requestFailed": "Die Anfrage ist fehlgeschlagen (HTTP {{status}}).",
"outcomeUnknown": "Die Anfrage wurde unterbrochen und könnte bereits abgeschlossen sein. Prüfen Sie den aktuellen Zustand, bevor Sie sie erneut senden."
}
}
Loading
Loading