diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 2d1d4064..2e2eb043 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -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 }} @@ -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" diff --git a/api/types.go b/api/types.go index 1766e819..854c4ed9 100644 --- a/api/types.go +++ b/api/types.go @@ -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"` diff --git a/auth/oauth.go b/auth/oauth.go index 811130bd..d2bf8295 100644 --- a/auth/oauth.go +++ b/auth/oauth.go @@ -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 "" + } +} diff --git a/auth/oauth_actions_test.go b/auth/oauth_actions_test.go new file mode 100644 index 00000000..0e480752 --- /dev/null +++ b/auth/oauth_actions_test.go @@ -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") + } + }) + } +} diff --git a/cmd/skillpack/main.go b/cmd/skillpack/main.go new file mode 100644 index 00000000..cfd66205 --- /dev/null +++ b/cmd/skillpack/main.go @@ -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 +} diff --git a/cmd/skillpack/main_test.go b/cmd/skillpack/main_test.go new file mode 100644 index 00000000..a2580993 --- /dev/null +++ b/cmd/skillpack/main_test.go @@ -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") + } + } +} diff --git a/database/store_cloud_billing_billing.go b/database/store_cloud_billing_billing.go index 05a25706..05c82dc1 100644 --- a/database/store_cloud_billing_billing.go +++ b/database/store_cloud_billing_billing.go @@ -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 diff --git a/database/store_oauth.go b/database/store_oauth.go index db3b59c1..48a8bd69 100644 --- a/database/store_oauth.go +++ b/database/store_oauth.go @@ -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) @@ -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 } diff --git a/frontend/dashboard/public/i18n/de.json b/frontend/dashboard/public/i18n/de.json index 6630a5a5..8a355957 100644 --- a/frontend/dashboard/public/i18n/de.json +++ b/frontend/dashboard/public/i18n/de.json @@ -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", @@ -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", @@ -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.", @@ -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", @@ -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." } } diff --git a/frontend/dashboard/public/i18n/en.json b/frontend/dashboard/public/i18n/en.json index f9113e43..9767d28e 100644 --- a/frontend/dashboard/public/i18n/en.json +++ b/frontend/dashboard/public/i18n/en.json @@ -26,7 +26,7 @@ "oauth": { "connectionDetails": "Connection details", "consentTitle": "Authorize an application", - "consentDescription": "Review the application and choose the sites it may access.", + "consentDescription": "Review the application, requested permissions and the team it may access.", "loading": "Loading applications and permissions…", "retry": "Try again", "application": "Application", @@ -43,7 +43,7 @@ "siteHelp": "Select each site explicitly. New sites will not be included automatically.", "noEligibleSites": "No sites are available for this authorization with your current permissions.", "permissionNotice": "The application receives only the selected access. You can revoke it in Connected applications.", - "approve": "Authorize selected sites", + "approve": "Authorize selected access", "deny": "Deny and cancel", "submitting": "Submitting your decision…", "cancel": "Return to HitKeep", @@ -64,7 +64,12 @@ "sitesRead": "List selected sites", "analyticsRead": "Read analytics for selected sites", "trackingDomainsRead": "Read tracking domains for selected sites", - "ingestWrite": "Send analytics events to selected sites" + "ingestWrite": "Send analytics events to selected sites", + "goalsWrite": "Manage goals and funnels for selected sites", + "annotationsWrite": "Manage annotations for selected sites", + "sitesWrite": "Change domains and retention for selected sites", + "opportunitiesWrite": "Manage opportunities for selected sites", + "billingWrite": "Start, review and cancel Checkout for the selected team; payment requires your approval" }, "signedInAs": "Signed in as {{ email }}", "wrongAccount": "To use another account, sign out from the account menu.", @@ -74,7 +79,9 @@ "continueAuthorization": "Continue to authorization", "resumeDescription": "Your application authorization is still pending. Continue to review the requested access.", "checkoutWaiting": "Checkout is not confirmed yet. Finish the existing billing flow or try again.", - "resumeFailed": "The pending authorization could not be restored. Your billing settings are still available. Try again." + "resumeFailed": "The pending authorization could not be restored. Your billing settings are still available. Try again.", + "noEligibleTeams": "No teams are available for this authorization with your current permissions.", + "billingHelp": "This access applies to the selected team. Payment still requires your confirmation in Stripe Checkout." }, "nav": { "resizeSidebar": "Resize sidebar", @@ -4143,5 +4150,19 @@ "invalid": "Enter a name and at least one HTTPS redirect URI within the field limits.", "revokeTitle": "Revoke OAuth client", "revokeConfirm": "Revoke {{application}}? This stops new authorizations and revokes all of its existing grants." + }, + "agentTools": { + "listTitle": "Available HitKeep actions", + "listDescription": "Discover the documented HitKeep API actions for this signed-in session. Credentials and exports use the normal UI.", + "runTitle": "Run a HitKeep action", + "runDescription": "Run a discovered API action with current permissions. Every change requires explicit user confirmation.", + "confirmTitle": "Confirm agent action", + "confirmAction": "Allow this agent action on team {{team}}?\n\n{{action}}\n\n{{details}}", + "unavailable": "Agent actions are unavailable in the current session or context.", + "invalidAction": "This action or its parameters are not available to agents.", + "notConfirmed": "The action was not confirmed.", + "confirmPending": "Another agent action is waiting for confirmation.", + "requestFailed": "The request failed (HTTP {{status}}).", + "outcomeUnknown": "The request was interrupted and may have completed. Check the current state before retrying." } } diff --git a/frontend/dashboard/public/i18n/es.json b/frontend/dashboard/public/i18n/es.json index 1e4534b0..4670eda9 100644 --- a/frontend/dashboard/public/i18n/es.json +++ b/frontend/dashboard/public/i18n/es.json @@ -26,7 +26,7 @@ "oauth": { "connectionDetails": "Detalles de la conexión", "consentTitle": "Autorizar una aplicación", - "consentDescription": "Revisa la aplicación y elige los sitios a los que podrá acceder.", + "consentDescription": "Revisa la aplicación, los permisos solicitados y el equipo al que podrá acceder.", "loading": "Cargando aplicaciones y permisos…", "retry": "Volver a intentar", "application": "Aplicación", @@ -43,7 +43,7 @@ "siteHelp": "Selecciona cada sitio de forma explícita. Los sitios nuevos no se incluirán automáticamente.", "noEligibleSites": "Tus permisos actuales no permiten autorizar ningún sitio.", "permissionNotice": "La aplicación recibe solo el acceso seleccionado. Puedes revocarlo en Aplicaciones conectadas.", - "approve": "Autorizar los sitios seleccionados", + "approve": "Autorizar el acceso seleccionado", "deny": "Denegar y cancelar", "submitting": "Enviando tu decisión…", "cancel": "Volver a HitKeep", @@ -64,7 +64,12 @@ "sitesRead": "Listar los sitios seleccionados", "analyticsRead": "Leer las analíticas de los sitios seleccionados", "trackingDomainsRead": "Leer los dominios de seguimiento de los sitios seleccionados", - "ingestWrite": "Enviar eventos de analítica a los sitios seleccionados" + "ingestWrite": "Enviar eventos de analítica a los sitios seleccionados", + "goalsWrite": "Gestionar objetivos y embudos de los sitios seleccionados", + "annotationsWrite": "Gestionar anotaciones de los sitios seleccionados", + "sitesWrite": "Cambiar dominios y retención de los sitios seleccionados", + "opportunitiesWrite": "Gestionar oportunidades de los sitios seleccionados", + "billingWrite": "Iniciar, revisar y cancelar Checkout para el equipo seleccionado; el pago requiere tu aprobación" }, "signedInAs": "Sesión iniciada como {{ email }}", "wrongAccount": "Para usar otra cuenta, cierra sesión desde el menú de la cuenta.", @@ -74,7 +79,9 @@ "continueAuthorization": "Continuar con la autorización", "resumeDescription": "La autorización de la aplicación sigue pendiente. Continúa para revisar el acceso solicitado.", "checkoutWaiting": "El pago aún no está confirmado. Completa el proceso de facturación existente o vuelve a intentarlo.", - "resumeFailed": "No se pudo restaurar la autorización pendiente. La configuración de facturación sigue disponible. Vuelve a intentarlo." + "resumeFailed": "No se pudo restaurar la autorización pendiente. La configuración de facturación sigue disponible. Vuelve a intentarlo.", + "noEligibleTeams": "No hay equipos disponibles para esta autorización con tus permisos actuales.", + "billingHelp": "Este acceso se aplica al equipo seleccionado. El pago sigue requiriendo tu confirmación en Stripe Checkout." }, "nav": { "resizeSidebar": "Cambiar el tamaño de la barra lateral", @@ -4287,5 +4294,19 @@ "invalid": "Introduce un nombre y al menos una URI de redirección HTTPS dentro de los límites de los campos.", "revokeTitle": "Revocar cliente OAuth", "revokeConfirm": "¿Revocar {{application}}? Esto impide nuevas autorizaciones y revoca todas las existentes." + }, + "agentTools": { + "listTitle": "Acciones disponibles de HitKeep", + "listDescription": "Descubre las acciones documentadas de la API de HitKeep para esta sesión. Las credenciales y exportaciones se gestionan en la interfaz habitual.", + "runTitle": "Ejecutar una acción de HitKeep", + "runDescription": "Ejecuta una acción disponible de la API con los permisos actuales. Cada cambio requiere la confirmación explícita del usuario.", + "confirmTitle": "Confirmar acción del agente", + "confirmAction": "¿Permitir esta acción del agente en el equipo {{team}}?\n\n{{action}}\n\n{{details}}", + "unavailable": "Las acciones de agentes no están disponibles en la sesión o el contexto actuales.", + "invalidAction": "Esta acción o sus parámetros no están disponibles para agentes.", + "notConfirmed": "La acción no se ha confirmado.", + "confirmPending": "Otra acción del agente está esperando confirmación.", + "requestFailed": "La solicitud ha fallado (HTTP {{status}}).", + "outcomeUnknown": "La solicitud se ha interrumpido y podría haberse completado. Comprueba el estado actual antes de volver a intentarlo." } } diff --git a/frontend/dashboard/public/i18n/fr.json b/frontend/dashboard/public/i18n/fr.json index c0943e22..3be87ab5 100644 --- a/frontend/dashboard/public/i18n/fr.json +++ b/frontend/dashboard/public/i18n/fr.json @@ -26,7 +26,7 @@ "oauth": { "connectionDetails": "Détails de la connexion", "consentTitle": "Autoriser une application", - "consentDescription": "Vérifiez l’application et choisissez les sites auxquels elle pourra accéder.", + "consentDescription": "Vérifiez l’application, les autorisations demandées et l’équipe à laquelle elle pourra accéder.", "loading": "Chargement des applications et des autorisations…", "retry": "Réessayer", "application": "Application", @@ -43,7 +43,7 @@ "siteHelp": "Sélectionnez chaque site explicitement. Les nouveaux sites ne seront pas inclus automatiquement.", "noEligibleSites": "Vos autorisations actuelles ne permettent d’inclure aucun site dans cette autorisation.", "permissionNotice": "L’application reçoit uniquement l’accès sélectionné. Vous pouvez le révoquer dans Applications connectées.", - "approve": "Autoriser les sites sélectionnés", + "approve": "Autoriser l’accès sélectionné", "deny": "Refuser et annuler", "submitting": "Envoi de votre décision…", "cancel": "Retour à HitKeep", @@ -64,7 +64,12 @@ "sitesRead": "Lister les sites sélectionnés", "analyticsRead": "Lire les statistiques des sites sélectionnés", "trackingDomainsRead": "Lire les domaines de suivi des sites sélectionnés", - "ingestWrite": "Envoyer des événements analytiques aux sites sélectionnés" + "ingestWrite": "Envoyer des événements analytiques aux sites sélectionnés", + "goalsWrite": "Gérer les objectifs et les entonnoirs des sites sélectionnés", + "annotationsWrite": "Gérer les annotations des sites sélectionnés", + "sitesWrite": "Modifier les domaines et la conservation des sites sélectionnés", + "opportunitiesWrite": "Gérer les opportunités des sites sélectionnés", + "billingWrite": "Démarrer, consulter et annuler Checkout pour l’équipe sélectionnée ; le paiement nécessite votre approbation" }, "signedInAs": "Connecté en tant que {{ email }}", "wrongAccount": "Pour utiliser un autre compte, déconnectez-vous depuis le menu du compte.", @@ -74,7 +79,9 @@ "continueAuthorization": "Poursuivre l’autorisation", "resumeDescription": "L’autorisation de votre application est toujours en attente. Continuez pour examiner l’accès demandé.", "checkoutWaiting": "Le paiement n’est pas encore confirmé. Terminez le processus de facturation en cours ou réessayez.", - "resumeFailed": "Impossible de restaurer l’autorisation en attente. Vos paramètres de facturation restent accessibles. Réessayez." + "resumeFailed": "Impossible de restaurer l’autorisation en attente. Vos paramètres de facturation restent accessibles. Réessayez.", + "noEligibleTeams": "Aucune équipe n’est disponible pour cette autorisation avec vos droits actuels.", + "billingHelp": "Cet accès concerne l’équipe sélectionnée. Le paiement nécessite toujours votre confirmation dans Stripe Checkout." }, "nav": { "resizeSidebar": "Redimensionner la barre latérale", @@ -4036,5 +4043,19 @@ "invalid": "Saisissez un nom et au moins une URI de redirection HTTPS dans les limites des champs.", "revokeTitle": "Révoquer le client OAuth", "revokeConfirm": "Révoquer {{application}} ? Cela empêche toute nouvelle autorisation et révoque toutes ses autorisations existantes." + }, + "agentTools": { + "listTitle": "Actions HitKeep disponibles", + "listDescription": "Découvrez les actions documentées de l’API HitKeep pour cette session. Les identifiants et les exports restent dans l’interface habituelle.", + "runTitle": "Exécuter une action HitKeep", + "runDescription": "Exécutez une action disponible de l’API avec les autorisations actuelles. Chaque modification nécessite une confirmation explicite.", + "confirmTitle": "Confirmer l’action de l’agent", + "confirmAction": "Autoriser cette action de l’agent pour l’équipe {{team}} ?\n\n{{action}}\n\n{{details}}", + "unavailable": "Les actions des agents ne sont pas disponibles dans la session ou le contexte actuels.", + "invalidAction": "Cette action ou ses paramètres ne sont pas disponibles pour les agents.", + "notConfirmed": "L’action n’a pas été confirmée.", + "confirmPending": "Une autre action de l’agent attend une confirmation.", + "requestFailed": "La requête a échoué (HTTP {{status}}).", + "outcomeUnknown": "La requête a été interrompue et a peut-être abouti. Vérifiez l’état actuel avant de réessayer." } } diff --git a/frontend/dashboard/public/i18n/it.json b/frontend/dashboard/public/i18n/it.json index 07fe7ec1..4e099b7a 100644 --- a/frontend/dashboard/public/i18n/it.json +++ b/frontend/dashboard/public/i18n/it.json @@ -26,7 +26,7 @@ "oauth": { "connectionDetails": "Dettagli della connessione", "consentTitle": "Autorizza un’applicazione", - "consentDescription": "Controlla l’applicazione e scegli i siti a cui potrà accedere.", + "consentDescription": "Controlla l’applicazione, le autorizzazioni richieste e il team a cui potrà accedere.", "loading": "Caricamento di applicazioni e autorizzazioni…", "retry": "Riprova", "application": "Applicazione", @@ -43,7 +43,7 @@ "siteHelp": "Seleziona esplicitamente ogni sito. I nuovi siti non saranno inclusi automaticamente.", "noEligibleSites": "Le tue autorizzazioni attuali non consentono di includere alcun sito in questa autorizzazione.", "permissionNotice": "L’applicazione riceve solo l’accesso selezionato. Puoi revocarlo in Applicazioni collegate.", - "approve": "Autorizza i siti selezionati", + "approve": "Autorizza l’accesso selezionato", "deny": "Rifiuta e annulla", "submitting": "Invio della decisione…", "cancel": "Torna a HitKeep", @@ -64,7 +64,12 @@ "sitesRead": "Elencare i siti selezionati", "analyticsRead": "Leggere le analisi dei siti selezionati", "trackingDomainsRead": "Leggere i domini di tracciamento dei siti selezionati", - "ingestWrite": "Inviare eventi di analisi ai siti selezionati" + "ingestWrite": "Inviare eventi di analisi ai siti selezionati", + "goalsWrite": "Gestire obiettivi e funnel dei siti selezionati", + "annotationsWrite": "Gestire annotazioni dei siti selezionati", + "sitesWrite": "Modificare domini e conservazione dei siti selezionati", + "opportunitiesWrite": "Gestire opportunità dei siti selezionati", + "billingWrite": "Avviare, controllare e annullare Checkout per il team selezionato; il pagamento richiede la tua approvazione" }, "signedInAs": "Accesso effettuato come {{ email }}", "wrongAccount": "Per usare un altro account, esci dal menu dell’account.", @@ -74,7 +79,9 @@ "continueAuthorization": "Continua con l’autorizzazione", "resumeDescription": "L’autorizzazione dell’applicazione è ancora in sospeso. Continua per controllare l’accesso richiesto.", "checkoutWaiting": "Il pagamento non è ancora confermato. Completa la procedura di fatturazione in corso o riprova.", - "resumeFailed": "Impossibile ripristinare l’autorizzazione in sospeso. Le impostazioni di fatturazione restano disponibili. Riprova." + "resumeFailed": "Impossibile ripristinare l’autorizzazione in sospeso. Le impostazioni di fatturazione restano disponibili. Riprova.", + "noEligibleTeams": "Nessun team è disponibile per questa autorizzazione con i tuoi permessi attuali.", + "billingHelp": "Questo accesso si applica al team selezionato. Il pagamento richiede comunque la tua conferma in Stripe Checkout." }, "nav": { "resizeSidebar": "Ridimensiona barra laterale", @@ -4033,5 +4040,19 @@ "invalid": "Inserisci un nome e almeno un URI di reindirizzamento HTTPS entro i limiti dei campi.", "revokeTitle": "Revoca client OAuth", "revokeConfirm": "Revocare {{application}}? Questa azione impedisce nuove autorizzazioni e revoca tutte quelle esistenti." + }, + "agentTools": { + "listTitle": "Azioni HitKeep disponibili", + "listDescription": "Scopri le azioni documentate dell’API HitKeep per questa sessione. Credenziali ed esportazioni restano nell’interfaccia abituale.", + "runTitle": "Esegui un’azione HitKeep", + "runDescription": "Esegui un’azione disponibile dell’API con le autorizzazioni attuali. Ogni modifica richiede una conferma esplicita.", + "confirmTitle": "Conferma azione dell’agente", + "confirmAction": "Consentire questa azione dell’agente per il team {{team}}?\n\n{{action}}\n\n{{details}}", + "unavailable": "Le azioni degli agenti non sono disponibili nella sessione o nel contesto attuali.", + "invalidAction": "Questa azione o i suoi parametri non sono disponibili per gli agenti.", + "notConfirmed": "L’azione non è stata confermata.", + "confirmPending": "Un’altra azione dell’agente è in attesa di conferma.", + "requestFailed": "La richiesta non è riuscita (HTTP {{status}}).", + "outcomeUnknown": "La richiesta è stata interrotta e potrebbe essere stata completata. Verifica lo stato attuale prima di riprovare." } } diff --git a/frontend/dashboard/public/i18n/nl.json b/frontend/dashboard/public/i18n/nl.json index 1bb0265f..7d8f0e4b 100644 --- a/frontend/dashboard/public/i18n/nl.json +++ b/frontend/dashboard/public/i18n/nl.json @@ -26,7 +26,7 @@ "oauth": { "connectionDetails": "Verbindingsgegevens", "consentTitle": "Een toepassing autoriseren", - "consentDescription": "Controleer de toepassing en kies de websites waartoe deze toegang krijgt.", + "consentDescription": "Controleer de applicatie, de gevraagde rechten en het team waartoe deze toegang krijgt.", "loading": "Toepassingen en rechten laden…", "retry": "Opnieuw proberen", "application": "Toepassing", @@ -43,7 +43,7 @@ "siteHelp": "Selecteer elke website expliciet. Nieuwe websites worden niet automatisch toegevoegd.", "noEligibleSites": "Met je huidige rechten zijn er geen websites beschikbaar voor deze autorisatie.", "permissionNotice": "De toepassing krijgt alleen de geselecteerde toegang. Je kunt deze intrekken bij Verbonden toepassingen.", - "approve": "Geselecteerde websites autoriseren", + "approve": "Geselecteerde toegang toestaan", "deny": "Weigeren en annuleren", "submitting": "Je beslissing verzenden…", "cancel": "Terug naar HitKeep", @@ -64,7 +64,12 @@ "sitesRead": "Geselecteerde websites weergeven", "analyticsRead": "Analytics voor geselecteerde websites lezen", "trackingDomainsRead": "Trackingdomeinen van geselecteerde websites lezen", - "ingestWrite": "Analyticsgebeurtenissen naar geselecteerde websites sturen" + "ingestWrite": "Analyticsgebeurtenissen naar geselecteerde websites sturen", + "goalsWrite": "Doelen en trechters voor geselecteerde sites beheren", + "annotationsWrite": "Annotaties voor geselecteerde sites beheren", + "sitesWrite": "Domeinen en bewaartermijnen voor geselecteerde sites wijzigen", + "opportunitiesWrite": "Kansen voor geselecteerde sites beheren", + "billingWrite": "Checkout voor het geselecteerde team starten, bekijken en annuleren; betaling vereist je goedkeuring" }, "signedInAs": "Aangemeld als {{ email }}", "wrongAccount": "Meld je af via het accountmenu om een ander account te gebruiken.", @@ -74,7 +79,9 @@ "continueAuthorization": "Doorgaan naar autorisatie", "resumeDescription": "De autorisatie van je toepassing staat nog open. Ga verder om de gevraagde toegang te bekijken.", "checkoutWaiting": "De betaling is nog niet bevestigd. Rond het bestaande betaalproces af of probeer het opnieuw.", - "resumeFailed": "De openstaande autorisatie kon niet worden hersteld. Je factureringsinstellingen blijven beschikbaar. Probeer het opnieuw." + "resumeFailed": "De openstaande autorisatie kon niet worden hersteld. Je factureringsinstellingen blijven beschikbaar. Probeer het opnieuw.", + "noEligibleTeams": "Met je huidige rechten zijn er geen teams beschikbaar voor deze autorisatie.", + "billingHelp": "Deze toegang geldt voor het geselecteerde team. Betaling vereist nog steeds je bevestiging in Stripe Checkout." }, "nav": { "resizeSidebar": "Breedte van zijbalk wijzigen", @@ -4019,5 +4026,19 @@ "invalid": "Voer een naam en minstens één HTTPS-omleidings-URI in binnen de veldlimieten.", "revokeTitle": "OAuth-client intrekken", "revokeConfirm": "{{application}} intrekken? Dit blokkeert nieuwe autorisaties en trekt alle bestaande machtigingen in." + }, + "agentTools": { + "listTitle": "Beschikbare HitKeep-acties", + "listDescription": "Ontdek de gedocumenteerde HitKeep-API-acties voor deze aangemelde sessie. Inloggegevens en exports blijven in de gewone interface.", + "runTitle": "Een HitKeep-actie uitvoeren", + "runDescription": "Voer een beschikbare API-actie uit met de huidige rechten. Elke wijziging vereist uitdrukkelijke bevestiging.", + "confirmTitle": "Agentactie bevestigen", + "confirmAction": "Deze agentactie toestaan voor team {{team}}?\n\n{{action}}\n\n{{details}}", + "unavailable": "Agentacties zijn niet beschikbaar in de huidige sessie of context.", + "invalidAction": "Deze actie of de bijbehorende parameters zijn niet beschikbaar voor agents.", + "notConfirmed": "De actie is niet bevestigd.", + "confirmPending": "Een andere agentactie wacht op bevestiging.", + "requestFailed": "Het verzoek is mislukt (HTTP {{status}}).", + "outcomeUnknown": "Het verzoek is onderbroken en kan al zijn uitgevoerd. Controleer de huidige status voordat je het opnieuw probeert." } } diff --git a/frontend/dashboard/public/i18n/pt.json b/frontend/dashboard/public/i18n/pt.json index e70b669a..e14248d5 100644 --- a/frontend/dashboard/public/i18n/pt.json +++ b/frontend/dashboard/public/i18n/pt.json @@ -26,7 +26,7 @@ "oauth": { "connectionDetails": "Detalhes da conexão", "consentTitle": "Autorizar um aplicativo", - "consentDescription": "Revise o aplicativo e escolha os sites que ele poderá acessar.", + "consentDescription": "Revise o aplicativo, as permissões solicitadas e a equipe que ele poderá acessar.", "loading": "Carregando aplicativos e permissões…", "retry": "Tentar novamente", "application": "Aplicativo", @@ -43,7 +43,7 @@ "siteHelp": "Selecione cada site explicitamente. Os novos sites não serão incluídos automaticamente.", "noEligibleSites": "As suas permissões atuais não permitem incluir sites nesta autorização.", "permissionNotice": "O aplicativo recebe apenas o acesso selecionado. Você pode revogá-lo em Aplicativos conectados.", - "approve": "Autorizar os sites selecionados", + "approve": "Autorizar o acesso selecionado", "deny": "Recusar e cancelar", "submitting": "Enviando sua decisão…", "cancel": "Voltar para o HitKeep", @@ -64,7 +64,12 @@ "sitesRead": "Listar os sites selecionados", "analyticsRead": "Ler as análises dos sites selecionados", "trackingDomainsRead": "Ler os domínios de rastreio dos sites selecionados", - "ingestWrite": "Enviar eventos de análise para os sites selecionados" + "ingestWrite": "Enviar eventos de análise para os sites selecionados", + "goalsWrite": "Gerenciar metas e funis dos sites selecionados", + "annotationsWrite": "Gerenciar anotações dos sites selecionados", + "sitesWrite": "Alterar domínios e retenção dos sites selecionados", + "opportunitiesWrite": "Gerenciar oportunidades dos sites selecionados", + "billingWrite": "Iniciar, revisar e cancelar Checkout para a equipe selecionada; o pagamento exige sua aprovação" }, "signedInAs": "Conectado como {{ email }}", "wrongAccount": "Para usar outra conta, saia pelo menu da conta.", @@ -74,7 +79,9 @@ "continueAuthorization": "Continuar para a autorização", "resumeDescription": "A autorização do aplicativo ainda está pendente. Continue para revisar o acesso solicitado.", "checkoutWaiting": "O pagamento ainda não foi confirmado. Conclua o processo de cobrança em andamento ou tente novamente.", - "resumeFailed": "Não foi possível restaurar a autorização pendente. Suas configurações de cobrança continuam disponíveis. Tente novamente." + "resumeFailed": "Não foi possível restaurar a autorização pendente. Suas configurações de cobrança continuam disponíveis. Tente novamente.", + "noEligibleTeams": "Nenhuma equipe está disponível para esta autorização com suas permissões atuais.", + "billingHelp": "Este acesso se aplica à equipe selecionada. O pagamento ainda exige sua confirmação no Stripe Checkout." }, "nav": { "resizeSidebar": "Redimensionar barra lateral", @@ -4009,5 +4016,19 @@ "invalid": "Insira um nome e pelo menos uma URI de redirecionamento HTTPS dentro dos limites dos campos.", "revokeTitle": "Revogar cliente OAuth", "revokeConfirm": "Revogar {{application}}? Isso impede novas autorizações e revoga todas as existentes." + }, + "agentTools": { + "listTitle": "Ações disponíveis do HitKeep", + "listDescription": "Descubra as ações documentadas da API do HitKeep para esta sessão. As credenciais e exportações continuam na interface habitual.", + "runTitle": "Executar uma ação do HitKeep", + "runDescription": "Execute uma ação disponível da API com as permissões atuais. Cada alteração exige confirmação explícita.", + "confirmTitle": "Confirmar ação do agente", + "confirmAction": "Permitir esta ação do agente na equipa {{team}}?\n\n{{action}}\n\n{{details}}", + "unavailable": "As ações dos agentes não estão disponíveis na sessão ou no contexto atuais.", + "invalidAction": "Esta ação ou os seus parâmetros não estão disponíveis para agentes.", + "notConfirmed": "A ação não foi confirmada.", + "confirmPending": "Outra ação do agente está à espera de confirmação.", + "requestFailed": "O pedido falhou (HTTP {{status}}).", + "outcomeUnknown": "O pedido foi interrompido e poderá ter sido concluído. Verifique o estado atual antes de tentar novamente." } } diff --git a/frontend/dashboard/src/app/core/agent-tools/api-operations.spec.ts b/frontend/dashboard/src/app/core/agent-tools/api-operations.spec.ts new file mode 100644 index 00000000..90491ba2 --- /dev/null +++ b/frontend/dashboard/src/app/core/agent-tools/api-operations.spec.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { agentOperations, agentRequest, redactAgentResult } from './api-operations'; + +const document = { + paths: { + '/api/sites/{id}/goals': { + get: { summary: 'List goals', tags: ['Goals'] }, + post: { summary: 'Create a goal', tags: ['Goals'], requestBody: { content: { 'application/json': { schema: { type: 'object' } } } } } + }, + '/api/user/password': { put: { summary: 'Change password', tags: ['User'] } }, + '/api/user/api-clients': { post: { summary: 'Create token', tags: ['User'] } }, + '/api/sites/{id}/share': { post: { summary: 'Create share token', tags: ['Share'] } }, + '/api/webhooks': { post: { summary: 'Create signing secret', tags: ['Webhooks'] } }, + '/api/user/teams/active': { put: { summary: 'Switch team', tags: ['Teams'] } }, + '/api/export': { get: { responses: { '200': { content: { 'text/csv': {} } } } } }, + '//external.example/api': { get: {} }, + '/api/../auth': { get: {} } + } +}; + +describe('agent API operations', () => { + it('reuses declared API operations while keeping credentials and downloads in the trusted UI', () => { + expect(agentOperations(document).map((operation) => operation.id)).toEqual(['GET /api/sites/{id}/goals', 'POST /api/sites/{id}/goals']); + }); + + it('handles missing or malformed OpenAPI data without exposing operations', () => { + for (const value of [null, {}, { paths: [] }, { paths: { '/api/sites': null } }]) expect(agentOperations(value)).toEqual([]); + }); + + it('omits protocol operations that require headers the dashboard bridge cannot supply', () => { + expect( + agentOperations({ + paths: { + '/api/commerce/checkout_sessions': { parameters: [{ in: 'header', name: 'API-Version', required: true }], post: {} }, + '/api/ucp/checkout-sessions': { post: { parameters: [{ in: 'header', name: 'UCP-Agent', required: true }] } }, + '/api/sites/{id}/goals': { parameters: [{ in: 'path', name: 'id', required: true }], get: { parameters: [{ in: 'query', name: 'limit' }] } } + } + }) + ).toMatchObject([{ id: 'GET /api/sites/{id}/goals', parameters: [{ name: 'id' }, { name: 'limit' }] }]); + }); + + it('keeps tokenized links, binary downloads and live streams in their existing UI flows', () => { + expect( + agentOperations({ + paths: { + '/api/reports/unsubscribe/{opaque_token}': { get: {} }, + '/api/sites/{id}/realtime': { get: { 'x-internal': true } }, + '/api/sites/{id}/export': { get: {} }, + '/api/user/avatar': { get: {} }, + '/api/favicon/{domain}': { get: {} }, + '/api/sites/{id}/qr-codes/{qrID}/asset': { get: {}, put: {}, delete: {} }, + '/api/upload': { put: { requestBody: { content: { 'multipart/form-data': {} } } } }, + '/api/internal': { get: { 'x-internal': true } }, + '/api/sites/{id}/goals': { get: {} } + } + }).map((operation) => operation.id) + ).toEqual(['DELETE /api/sites/{id}/qr-codes/{qrID}/asset', 'GET /api/sites/{id}/goals']); + }); + + it('builds only declared relative requests with encoded path and query values', () => { + const operation = agentOperations(document)[1]; + expect(agentRequest(operation, { path_parameters: { id: 'site-1' }, query: { q: 'a & b', page: 2 }, body: { name: 'Purchase' } })).toEqual({ + method: 'POST', + url: '/api/sites/site-1/goals?q=a+%26+b&page=2', + body: { name: 'Purchase' } + }); + }); + + it('rejects missing, traversal and encoded path separators before any request', () => { + const operation = agentOperations(document)[0]; + for (const id of ['', '.', '..', '../other', 'a/b', '%2fadmin', 'a\\b', 'a?b', 'a#b']) { + expect(() => agentRequest(operation, { path_parameters: { id } })).toThrow(); + } + expect(() => agentRequest(operation, {})).toThrow(); + }); + + it('rejects credential query parameters, nested query values and oversized JSON', () => { + const operation = agentOperations(document)[1]; + for (const query of [{ access_token: 'secret' }, { authorization: 'secret' }, { nested: { value: 1 } }]) { + expect(() => agentRequest(operation, { path_parameters: { id: 'site-1' }, query })).toThrow(); + } + expect(() => agentRequest(operation, { path_parameters: { id: 'site-1' }, body: { name: 'x'.repeat(65536) } })).toThrow(); + }); + + it('freezes the request body before asking the user to approve it', () => { + const body = { name: 'Approved name' }; + const request = agentRequest(agentOperations(document)[1], { path_parameters: { id: 'site-1' }, body }); + body.name = 'Different name'; + expect(request.body).toEqual({ name: 'Approved name' }); + }); + + it('removes recoverable credentials from nested API results without hiding analytics counts', () => { + expect(redactAgentResult({ access_token: 'secret', entries: [{ signing_secret: 'secret', name: 'Goal' }], tokens_used: 42, token_hint: '…abcd' })).toEqual({ + access_token: '[redacted]', + entries: [{ signing_secret: '[redacted]', name: 'Goal' }], + tokens_used: 42, + token_hint: '…abcd' + }); + }); +}); diff --git a/frontend/dashboard/src/app/core/agent-tools/api-operations.ts b/frontend/dashboard/src/app/core/agent-tools/api-operations.ts new file mode 100644 index 00000000..77819853 --- /dev/null +++ b/frontend/dashboard/src/app/core/agent-tools/api-operations.ts @@ -0,0 +1,91 @@ +export interface AgentOperation { + id: string; + method: string; + path: string; + summary: string; + tags: string[]; + parameters: unknown; + requestBody?: unknown; +} + +const methods = new Set(['get', 'post', 'put', 'patch', 'delete']); +const credentialPath = /\/(?:auth|oauth|login|logout|setup|api[-_]clients|share|takeout|webhooks?|password|passkeys?|mfa|totp|recovery|sso|social|unsubscribe|exports?|avatar|favicon|realtime)(?:\/|$)|\{[^}]*token[^}]*\}/i; +const credentialKey = /password|secret|private_key|recovery_codes?|authorization|(?:^|_)(?:access_token|refresh_token|id_token|api_key|token)$/i; + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; +} + +export function agentOperations(document: unknown): AgentOperation[] { + const paths = record(record(document)?.['paths']); + if (!paths) return []; + return Object.entries(paths).flatMap(([path, value]) => { + const item = record(value); + if (!item || !/^\/api\/[\w/{}.-]+$/.test(path) || path.includes('..') || path.includes('//') || credentialPath.test(path) || path === '/api/user/teams/active') return []; + return Object.entries(item).flatMap(([method, value]) => { + const operation = record(value); + if (!methods.has(method) || !operation || operation['x-internal'] === true || item['x-internal'] === true) return []; + if (path.endsWith('/asset') && method !== 'delete') return []; + const requestContent = record(record(operation['requestBody'])?.['content']); + if (requestContent && !Object.keys(requestContent).some((type) => type === 'application/json' || type.endsWith('+json'))) return []; + const tags = Array.isArray(operation['tags']) ? operation['tags'].filter((tag): tag is string => typeof tag === 'string') : []; + if (tags.some((tag) => ['Auth', 'Share', 'Takeout', 'Webhooks'].includes(tag))) return []; + const parameters = [...(Array.isArray(item['parameters']) ? item['parameters'] : []), ...(Array.isArray(operation['parameters']) ? operation['parameters'] : [])]; + if ( + parameters.some((value) => { + const parameter = record(value); + return parameter?.['in'] === 'header' && parameter['required'] === true; + }) + ) + return []; + const responses = record(operation['responses']); + const success = record(responses?.['200']); + const content = record(success?.['content']); + if (content && !Object.keys(content).some((type) => type === 'application/json' || type.endsWith('+json'))) return []; + return [ + { + id: `${method.toUpperCase()} ${path}`, + method: method.toUpperCase(), + path, + summary: typeof operation['summary'] === 'string' ? operation['summary'] : `${method.toUpperCase()} ${path}`, + tags, + parameters, + ...(operation['requestBody'] ? { requestBody: operation['requestBody'] } : {}) + } + ]; + }); + }); +} + +export function agentRequest(operation: AgentOperation, value: unknown): { method: string; url: string; body?: unknown } { + const input = record(value); + if (!input) throw new Error('Invalid action input'); + const parameters = record(input['path_parameters']) ?? {}; + const path = operation.path.replace(/\{([^}]+)\}/g, (_, name: string) => { + const value = parameters[name]; + if (typeof value !== 'string' || !value || value.length > 2048 || value === '.' || value === '..' || /[/%\\?#]/.test(value)) throw new Error('Invalid path parameter'); + return encodeURIComponent(value); + }); + const query = input['query'] === undefined ? {} : record(input['query']); + if (!query) throw new Error('Invalid query'); + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (credentialKey.test(key) || !['string', 'number', 'boolean'].includes(typeof value) || (typeof value === 'number' && !Number.isFinite(value))) throw new Error('Invalid query parameter'); + search.set(key, String(value)); + } + let body = input['body']; + if (body !== undefined) { + const json = JSON.stringify(body); + if (!json || new TextEncoder().encode(json).byteLength > 65536) throw new Error('Invalid request body'); + body = JSON.parse(json); + } + const suffix = search.toString(); + return { method: operation.method, url: path + (suffix ? `?${suffix}` : ''), ...(body !== undefined ? { body } : {}) }; +} + +export function redactAgentResult(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redactAgentResult); + const object = record(value); + if (!object) return value; + return Object.fromEntries(Object.entries(object).map(([key, value]) => [key, credentialKey.test(key) ? '[redacted]' : redactAgentResult(value)])); +} diff --git a/frontend/dashboard/src/app/core/layout/main-layout.html b/frontend/dashboard/src/app/core/layout/main-layout.html index 2d5e2362..c037ae82 100644 --- a/frontend/dashboard/src/app/core/layout/main-layout.html +++ b/frontend/dashboard/src/app/core/layout/main-layout.html @@ -36,3 +36,8 @@

{{ heading }} + + +

{{ confirmation.message }}

+
+
diff --git a/frontend/dashboard/src/app/core/layout/main-layout.spec.ts b/frontend/dashboard/src/app/core/layout/main-layout.spec.ts index 1d065b8c..e748de4a 100644 --- a/frontend/dashboard/src/app/core/layout/main-layout.spec.ts +++ b/frontend/dashboard/src/app/core/layout/main-layout.spec.ts @@ -24,6 +24,8 @@ import { LayoutSidebar } from './layout-sidebar'; import { MainLayoutContextService } from './main-layout-context.service'; import { MenuItem } from '@openng/optimus-ui/api'; import { vi } from 'vitest'; +import { AuthService } from '@services/auth.service'; +import type { DashboardAgentTool } from '@services/dashboard-webmcp.service'; interface LayoutSidebarTestAccess { closeMobileDrawer(): void; @@ -83,6 +85,10 @@ describe('MainLayout', () => { TranslocoTestingModule.forRoot({ langs: { en: { + agentTools: { + confirmTitle: 'Approve agent action', + confirmAction: 'Team: {{team}}\nAction: {{action}}\n{{details}}' + }, nav: { resizeSidebar: 'Resize sidebar', dashboard: 'Dashboard', @@ -273,6 +279,41 @@ describe('MainLayout', () => { expect(headings[0].textContent).toContain('Dashboard'); }); + it('renders agent approval details as text in the existing dialog and waits for a real user decision', async () => { + const original = Object.getOwnPropertyDescriptor(document, 'modelContext'); + const tools = new Map(); + Object.defineProperty(document, 'modelContext', { + configurable: true, + value: { + registerTool: async (tool: DashboardAgentTool, options: { signal: AbortSignal }) => { + tools.set(tool.name, tool); + options.signal.addEventListener('abort', () => tools.delete(tool.name), { once: true }); + } + } + }); + try { + TestBed.inject(AuthService).markAuthenticated(); + bootstrap.status.update((status) => ({ ...status!, ai_enabled: true })); + await fixture.whenStable(); + httpMock.expectOne('/api/openapi.json').flush({ paths: { '/api/sites/{id}/goals': { post: { summary: 'Create goal', tags: ['Goals'] } } } }); + await fixture.whenStable(); + const execution = tools.get('hitkeep.run_action')!.execute({ operation: 'POST /api/sites/{id}/goals', path_parameters: { id: 'site-1' }, body: { name: '' } }); + await fixture.whenStable(); + const dialog = document.querySelector('.p-confirmdialog')!; + expect(dialog.textContent).toContain(''); + expect(dialog.querySelector('img')).toBeNull(); + httpMock.expectNone('/api/sites/site-1/goals'); + (dialog.querySelector('.p-confirmdialog-reject-button') as HTMLButtonElement).click(); + expect(await execution).toMatchObject({ isError: true }); + httpMock.expectNone('/api/sites/site-1/goals'); + } finally { + bootstrap.status.update((status) => ({ ...status!, ai_enabled: false })); + await fixture.whenStable(); + if (original) Object.defineProperty(document, 'modelContext', original); + else Reflect.deleteProperty(document, 'modelContext'); + } + }); + it('offers contextual site actions under the existing permission and share boundaries', () => { const site = seedActiveSite(); expect(layoutContext.siteActions().map((item) => item.icon)).toEqual(['pi pi-cog', 'pi pi-share-alt', 'pi pi-plus']); diff --git a/frontend/dashboard/src/app/core/layout/main-layout.ts b/frontend/dashboard/src/app/core/layout/main-layout.ts index 67ac7a8d..3451d5d9 100644 --- a/frontend/dashboard/src/app/core/layout/main-layout.ts +++ b/frontend/dashboard/src/app/core/layout/main-layout.ts @@ -12,13 +12,16 @@ import { MainLayoutContextService } from '@layout/main-layout-context.service'; import { SidebarMenuService } from '@layout/sidebar-menu.service'; import { DashboardTitleService } from '@services/dashboard-title.service'; import { NavigationNoticeService } from '@services/navigation-notice.service'; +import { DashboardWebMCPService } from '@services/dashboard-webmcp.service'; +import { ConfirmationService } from '@openng/optimus-ui/api'; +import { ConfirmDialogModule } from '@openng/optimus-ui/confirmdialog'; @Component({ selector: 'app-main-layout', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, - providers: [MainLayoutContextService, SidebarMenuService], - imports: [NgTemplateOutlet, RouterOutlet, SplitterModule, LayoutSidebar, LayoutPageBar, LayoutOverlays, FreePlanRetentionNotice, MessageModule, TranslocoPipe], + providers: [MainLayoutContextService, SidebarMenuService, ConfirmationService, DashboardWebMCPService], + imports: [NgTemplateOutlet, RouterOutlet, SplitterModule, LayoutSidebar, LayoutPageBar, LayoutOverlays, FreePlanRetentionNotice, MessageModule, TranslocoPipe, ConfirmDialogModule], templateUrl: './main-layout.html', styleUrl: './main-layout.css' }) @@ -38,6 +41,7 @@ export class MainLayout { protected readonly minSizes = computed(() => [(224 / this.containerWidth()) * 100, 100 - (324 / this.containerWidth()) * 100]); constructor() { + inject(DashboardWebMCPService); afterNextRender(() => { const observer = new ResizeObserver(([entry]) => { const width = Math.max(1, entry.contentRect.width); diff --git a/frontend/dashboard/src/app/core/models/analytics.types.ts b/frontend/dashboard/src/app/core/models/analytics.types.ts index 5222aac8..a2b940d2 100644 --- a/frontend/dashboard/src/app/core/models/analytics.types.ts +++ b/frontend/dashboard/src/app/core/models/analytics.types.ts @@ -740,6 +740,7 @@ export interface AssistantConnection { export interface SystemStatus { needs_setup: boolean; version: string; + ai_enabled?: boolean; cloud?: CloudStatus; ask_ai?: AskAIStatus; mail_delivery?: { diff --git a/frontend/dashboard/src/app/core/services/dashboard-webmcp.service.spec.ts b/frontend/dashboard/src/app/core/services/dashboard-webmcp.service.spec.ts new file mode 100644 index 00000000..4f102aa3 --- /dev/null +++ b/frontend/dashboard/src/app/core/services/dashboard-webmcp.service.spec.ts @@ -0,0 +1,205 @@ +import { Component, inject, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TranslocoTestingModule } from '@jsverse/transloco'; +import { ConfirmationService } from '@openng/optimus-ui/api'; +import { vi } from 'vitest'; +import { DashboardWebMCPService, DashboardAgentTool } from './dashboard-webmcp.service'; +import { DashboardBootstrapService } from './dashboard-bootstrap.service'; +import { AuthService } from './auth.service'; +import { AccessService } from './access.service'; +import { MainLayoutContextService } from '@layout/main-layout-context.service'; +import { basePathInterceptor } from '@core/interceptors/base-path.interceptor'; + +@Component({ template: '', providers: [DashboardWebMCPService] }) +class Probe { + readonly tools = inject(DashboardWebMCPService); +} + +describe('DashboardWebMCPService', () => { + let fixture: ComponentFixture; + let http: HttpTestingController; + let tools: Map; + const status = signal({ ai_enabled: false }); + const authenticated = signal(true); + const share = signal(false); + const pending = signal(false); + const team = signal('team-1'); + const site = signal({ id: 'site-1' }); + const permissions = signal({}); + const confirmation = { confirm: vi.fn(), close: vi.fn() }; + let original: PropertyDescriptor | undefined; + + beforeEach(async () => { + tools = new Map(); + original = Object.getOwnPropertyDescriptor(document, 'modelContext'); + Object.defineProperty(document, 'modelContext', { + configurable: true, + value: { + registerTool: vi.fn(async (tool: DashboardAgentTool, options: { signal: AbortSignal }) => { + tools.set(tool.name, tool); + options.signal.addEventListener('abort', () => tools.delete(tool.name), { once: true }); + }) + } + }); + status.set({ ai_enabled: false }); + authenticated.set(true); + share.set(false); + pending.set(false); + team.set('team-1'); + site.set({ id: 'site-1' }); + permissions.set({}); + confirmation.confirm.mockReset(); + confirmation.close.mockReset(); + await TestBed.configureTestingModule({ + imports: [ + Probe, + TranslocoTestingModule.forRoot({ + langs: { en: { agentTools: { confirmTitle: 'Confirm agent action', confirmAction: '{{action}} on {{team}}\n{{details}}' }, common: { actions: { apply: 'Apply', cancel: 'Cancel' } } } }, + translocoConfig: { defaultLang: 'en', availableLangs: ['en'] } + }) + ], + providers: [ + provideHttpClient(withInterceptors([basePathInterceptor])), + provideHttpClientTesting(), + { provide: DashboardBootstrapService, useValue: { status } }, + { provide: AuthService, useValue: { isAuthenticated: authenticated } }, + { provide: AccessService, useValue: { context: permissions } }, + { provide: ConfirmationService, useValue: confirmation }, + { + provide: MainLayoutContextService, + useValue: { shareService: { isShareMode: share }, isContextPending: pending, teamService: { activeTeamId: team, activeTeam: () => ({ name: 'Team one' }) }, siteService: { activeSite: site } } + } + ] + }).compileComponents(); + fixture = TestBed.createComponent(Probe); + http = TestBed.inject(HttpTestingController); + await fixture.whenStable(); + }); + + afterEach(() => { + fixture.destroy(); + http.verify(); + if (original) Object.defineProperty(document, 'modelContext', original); + else Reflect.deleteProperty(document, 'modelContext'); + }); + + async function enable() { + status.set({ ai_enabled: true }); + await fixture.whenStable(); + http.expectOne('/api/openapi.json').flush({ paths: { '/api/sites/{id}/goals': { get: { summary: 'List goals', tags: ['Goals'] }, post: { summary: 'Create goal', tags: ['Goals'] } } } }); + await fixture.whenStable(); + } + + it('makes no schema request or registration while AI is disabled', () => { + http.expectNone('/api/openapi.json'); + expect(tools.size).toBe(0); + }); + + it('loads and advertises the schema through the base-path interceptor on a prefixed instance', async () => { + const existing = document.querySelector('base'); + const base = existing ?? document.createElement('base'); + const originalHref = base.getAttribute('href'); + if (!existing) document.head.append(base); + base.setAttribute('href', '/tenant/'); + try { + status.set({ ai_enabled: true }); + await fixture.whenStable(); + http.expectOne('/tenant/api/openapi.json').flush({ paths: {} }); + await fixture.whenStable(); + const catalog = await tools.get('hitkeep.list_actions')!.execute({}); + expect(JSON.stringify(catalog)).toContain('/tenant/api/openapi.json'); + } finally { + if (!existing) base.remove(); + else if (originalHref === null) base.removeAttribute('href'); + else base.setAttribute('href', originalHref); + } + }); + + it('gracefully skips browsers without WebMCP', async () => { + Reflect.deleteProperty(document, 'modelContext'); + status.set({ ai_enabled: true }); + await fixture.whenStable(); + http.expectNone('/api/openapi.json'); + expect(tools.size).toBe(0); + }); + + it('lists actual API operations and uses the existing authenticated HTTP client for reads', async () => { + await enable(); + const catalog = await tools.get('hitkeep.list_actions')!.execute({}); + expect(JSON.stringify(catalog)).toContain('GET /api/sites/{id}/goals'); + const result = tools.get('hitkeep.run_action')!.execute({ operation: 'GET /api/sites/{id}/goals', path_parameters: { id: 'site-1' } }); + const request = http.expectOne('/api/sites/site-1/goals'); + expect(request.request.headers.get('X-HitKeep-Agent')).toBe('webmcp'); + expect(request.request.headers.get('X-HitKeep-Agent-Team')).toBe('team-1'); + request.flush([{ name: 'Purchase' }]); + expect(JSON.stringify(await result)).toContain('Purchase'); + expect(confirmation.confirm).not.toHaveBeenCalled(); + }); + + it('waits for human confirmation before sending a mutation and redacts credentials in results', async () => { + await enable(); + const result = tools.get('hitkeep.run_action')!.execute({ operation: 'POST /api/sites/{id}/goals', path_parameters: { id: 'site-1' }, body: { name: 'Purchase' } }); + await Promise.resolve(); + http.expectNone('/api/sites/site-1/goals'); + const dialog = confirmation.confirm.mock.calls[0][0]; + expect(dialog.message).toContain('Create goal'); + expect(dialog.message).toContain('Purchase'); + dialog.accept(); + await Promise.resolve(); + http.expectOne('/api/sites/site-1/goals').flush({ name: 'Purchase', token: 'never-show-this' }); + expect(JSON.stringify(await result)).not.toContain('never-show-this'); + }); + + it('does not send a rejected mutation', async () => { + await enable(); + const result = tools.get('hitkeep.run_action')!.execute({ operation: 'POST /api/sites/{id}/goals', path_parameters: { id: 'site-1' }, body: {} }); + await Promise.resolve(); + confirmation.confirm.mock.calls[0][0].reject(); + expect(await result).toMatchObject({ isError: true }); + http.expectNone('/api/sites/site-1/goals'); + }); + + it('rejects stale callbacks immediately after logout or flag disable', async () => { + await enable(); + const run = tools.get('hitkeep.run_action')!; + authenticated.set(false); + expect(await run.execute({ operation: 'GET /api/sites/{id}/goals', path_parameters: { id: 'site-1' } })).toMatchObject({ isError: true }); + await fixture.whenStable(); + expect(tools.size).toBe(0); + http.expectNone('/api/sites/site-1/goals'); + }); + + it('cancels approval when the active context changes', async () => { + await enable(); + const run = tools.get('hitkeep.run_action')!; + const result = run.execute({ operation: 'POST /api/sites/{id}/goals', path_parameters: { id: 'site-1' }, body: {} }); + await Promise.resolve(); + const dialog = confirmation.confirm.mock.calls[0][0]; + team.set('team-2'); + dialog.accept(); + expect(await result).toMatchObject({ isError: true }); + http.expectNone('/api/sites/site-1/goals'); + }); + + it('blocks unknown and traversal operations without sending HTTP', async () => { + await enable(); + const run = tools.get('hitkeep.run_action')!; + for (const input of [{ operation: 'GET https://external.example/' }, { operation: 'GET /api/sites/{id}/goals', path_parameters: { id: '../admin' } }]) { + expect(await run.execute(input)).toMatchObject({ isError: true }); + } + expect(confirmation.confirm).not.toHaveBeenCalled(); + }); + + it('warns against retrying a mutation interrupted after it was sent', async () => { + await enable(); + const execution = tools.get('hitkeep.run_action')!.execute({ operation: 'POST /api/sites/{id}/goals', path_parameters: { id: 'site-1' }, body: { name: 'Purchase' } }); + await Promise.resolve(); + confirmation.confirm.mock.calls[0][0].accept(); + await Promise.resolve(); + const request = http.expectOne('/api/sites/site-1/goals'); + request.error(new ProgressEvent('error')); + expect(JSON.stringify(await execution)).toContain('agentTools.outcomeUnknown'); + }); +}); diff --git a/frontend/dashboard/src/app/core/services/dashboard-webmcp.service.ts b/frontend/dashboard/src/app/core/services/dashboard-webmcp.service.ts new file mode 100644 index 00000000..57425869 --- /dev/null +++ b/frontend/dashboard/src/app/core/services/dashboard-webmcp.service.ts @@ -0,0 +1,184 @@ +import { DOCUMENT } from '@angular/common'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { effect, inject, Service } from '@angular/core'; +import { TranslocoService } from '@jsverse/transloco'; +import { ConfirmationService } from '@openng/optimus-ui/api'; +import { firstValueFrom, fromEvent, takeUntil } from 'rxjs'; +import { agentOperations, AgentOperation, agentRequest, redactAgentResult } from '@core/agent-tools/api-operations'; +import { dialogCancelButton, dialogPrimaryButton } from '@components/dialog-actions/dialog-actions'; +import { MainLayoutContextService } from '@layout/main-layout-context.service'; +import { injectActiveLang } from '@core/i18n/active-lang'; +import { browserAppUrl } from '@core/interceptors/base-path.interceptor'; +import { AccessService } from './access.service'; +import { AuthService } from './auth.service'; +import { DashboardBootstrapService } from './dashboard-bootstrap.service'; + +export interface DashboardAgentTool { + name: string; + title: string; + description: string; + inputSchema: object; + annotations: { readOnlyHint?: boolean; consequentialHint?: boolean; untrustedContentHint?: boolean }; + execute: (input: unknown, options?: { signal?: AbortSignal }) => Promise; +} + +interface ModelContext { + registerTool(tool: DashboardAgentTool, options: { signal: AbortSignal }): Promise; +} + +function result(value: unknown): object { + return { content: [{ type: 'text', text: JSON.stringify(value) }] }; +} + +@Service({ autoProvided: false }) +export class DashboardWebMCPService { + private readonly document = inject(DOCUMENT); + private readonly http = inject(HttpClient); + private readonly auth = inject(AuthService); + private readonly bootstrap = inject(DashboardBootstrapService); + private readonly context = inject(MainLayoutContextService); + private readonly access = inject(AccessService); + private readonly confirmation = inject(ConfirmationService); + private readonly transloco = inject(TranslocoService); + private readonly language = injectActiveLang(); + private operations: AgentOperation[] | null = null; + private confirming = false; + + constructor() { + effect((cleanup) => { + this.language(); + const team = this.context.teamService.activeTeamId(); + const site = this.context.siteService.activeSite()?.id; + const permissions = this.access.context(); + const model = (this.document as Document & { modelContext?: ModelContext }).modelContext; + if (!this.available() || !model || !this.document.defaultView?.isSecureContext) return; + const controller = new AbortController(); + cleanup(() => controller.abort()); + const current = () => !controller.signal.aborted && this.available() && team === this.context.teamService.activeTeamId() && site === this.context.siteService.activeSite()?.id && permissions === this.access.context(); + void this.register(model, controller, current).catch(() => controller.abort()); + }); + } + + private available(): boolean { + return this.bootstrap.status()?.ai_enabled === true && this.auth.isAuthenticated() && !this.context.shareService.isShareMode() && !this.context.isContextPending(); + } + + private async register(model: ModelContext, controller: AbortController, current: () => boolean): Promise { + if (!this.operations) { + const schema = await firstValueFrom(this.http.get('/api/openapi.json').pipe(takeUntil(fromEvent(controller.signal, 'abort')))); + this.operations = agentOperations(schema); + } + if (!current()) return; + const operations = this.operations; + await Promise.all([ + model.registerTool( + { + name: 'hitkeep.list_actions', + title: this.transloco.translate('agentTools.listTitle'), + description: this.transloco.translate('agentTools.listDescription'), + inputSchema: { type: 'object', properties: { tag: { type: 'string' }, operation: { type: 'string' } }, additionalProperties: false }, + annotations: { readOnlyHint: true }, + execute: async (input) => { + if (!current()) return this.failure('agentTools.unavailable'); + const filter = input && typeof input === 'object' ? (input as { tag?: string; operation?: string }) : {}; + return result({ + openapi_url: browserAppUrl(this.document, '/api/openapi.json'), + team_id: this.context.teamService.activeTeamId(), + site_id: this.context.siteService.activeSite()?.id, + operations: operations.filter((operation) => (!filter.tag || operation.tags.includes(filter.tag)) && (!filter.operation || operation.id === filter.operation)) + }); + } + }, + { signal: controller.signal } + ), + model.registerTool( + { + name: 'hitkeep.run_action', + title: this.transloco.translate('agentTools.runTitle'), + description: this.transloco.translate('agentTools.runDescription'), + inputSchema: { + type: 'object', + properties: { + operation: { type: 'string' }, + path_parameters: { type: 'object', additionalProperties: { type: 'string' } }, + query: { type: 'object', additionalProperties: { type: ['string', 'number', 'boolean'] } }, + body: {} + }, + required: ['operation'], + additionalProperties: false + }, + annotations: { consequentialHint: true, untrustedContentHint: true }, + execute: (input, options) => this.run(operations, input, AbortSignal.any([controller.signal, ...(options?.signal ? [options.signal] : [])]), current) + }, + { signal: controller.signal } + ) + ]); + } + + private async run(operations: AgentOperation[], input: unknown, signal: AbortSignal, current: () => boolean): Promise { + let sentMutation = false; + try { + if (!current() || signal.aborted) throw new Error('agentTools.unavailable'); + const id = input && typeof input === 'object' ? (input as { operation?: unknown }).operation : null; + const operation = operations.find((operation) => operation.id === id); + if (!operation) throw new Error('agentTools.invalidAction'); + const request = agentRequest(operation, input); + if (operation.method !== 'GET') { + await this.approve(operation, request, signal); + } + if (!current() || signal.aborted) throw new Error('agentTools.unavailable'); + sentMutation = operation.method !== 'GET'; + const response = await firstValueFrom( + this.http + .request(request.method, request.url, { + body: request.body, + observe: 'response', + headers: { 'X-HitKeep-Agent': 'webmcp', 'X-HitKeep-Agent-Team': this.context.teamService.activeTeamId() } + }) + .pipe(takeUntil(fromEvent(signal, 'abort'))) + ); + if (!current() || signal.aborted) throw new Error('agentTools.unavailable'); + return result({ status: response.status, data: redactAgentResult(response.body) }); + } catch (error) { + if (sentMutation && (!(error instanceof HttpErrorResponse) || error.status === 0 || error.status >= 500)) return this.failure('agentTools.outcomeUnknown'); + if (error instanceof HttpErrorResponse) return this.failure('agentTools.requestFailed', { status: error.status }); + const key = error instanceof Error && error.message.startsWith('agentTools.') ? error.message : 'agentTools.invalidAction'; + return this.failure(key); + } + } + + private approve(operation: AgentOperation, request: { url: string; body?: unknown }, signal: AbortSignal): Promise { + if (this.confirming) return Promise.reject(new Error('agentTools.confirmPending')); + this.confirming = true; + return new Promise((resolve, reject) => { + const finish = (accepted: boolean) => { + signal.removeEventListener('abort', abort); + this.confirming = false; + if (accepted && !signal.aborted) resolve(); + else reject(new Error('agentTools.notConfirmed')); + }; + const abort = () => { + this.confirmation.close(); + finish(false); + }; + signal.addEventListener('abort', abort, { once: true }); + this.confirmation.confirm({ + key: 'agent-action', + header: this.transloco.translate('agentTools.confirmTitle'), + message: this.transloco.translate('agentTools.confirmAction', { + action: `${operation.summary}\n${operation.method} ${request.url}`, + team: this.context.teamService.activeTeam()?.name ?? this.context.teamService.activeTeamId(), + details: request.body === undefined ? '' : JSON.stringify(request.body, null, 2) + }), + rejectButtonProps: dialogCancelButton(this.transloco.translate('common.actions.cancel')), + acceptButtonProps: dialogPrimaryButton(this.transloco.translate('common.actions.apply')), + accept: () => finish(true), + reject: () => finish(false) + }); + }); + } + + private failure(key: string, params: Record = {}): object { + return { isError: true, content: [{ type: 'text', text: this.transloco.translate(key, params) }] }; + } +} diff --git a/frontend/dashboard/src/app/core/utils/oauth-display.spec.ts b/frontend/dashboard/src/app/core/utils/oauth-display.spec.ts index 628130f3..5816b298 100644 --- a/frontend/dashboard/src/app/core/utils/oauth-display.spec.ts +++ b/frontend/dashboard/src/app/core/utils/oauth-display.spec.ts @@ -1,11 +1,16 @@ import { formatOAuthTime, oauthScopeLabelKey } from './oauth-display'; describe('oauthScopeLabelKey', () => { - it('describes the four documented scopes without treating unknown values as permissions', () => { + it('describes documented scopes without treating unknown values as permissions', () => { expect(oauthScopeLabelKey('sites:read')).toBe('oauth.scope.sitesRead'); expect(oauthScopeLabelKey('analytics:read')).toBe('oauth.scope.analyticsRead'); expect(oauthScopeLabelKey('tracking_domains:read')).toBe('oauth.scope.trackingDomainsRead'); expect(oauthScopeLabelKey('ingest:write')).toBe('oauth.scope.ingestWrite'); + expect(oauthScopeLabelKey('goals:write')).toBe('oauth.scope.goalsWrite'); + expect(oauthScopeLabelKey('annotations:write')).toBe('oauth.scope.annotationsWrite'); + expect(oauthScopeLabelKey('sites:write')).toBe('oauth.scope.sitesWrite'); + expect(oauthScopeLabelKey('opportunities:write')).toBe('oauth.scope.opportunitiesWrite'); + expect(oauthScopeLabelKey('billing:write')).toBe('oauth.scope.billingWrite'); expect(oauthScopeLabelKey('admin:*')).toBeNull(); expect(oauthScopeLabelKey('__proto__')).toBeNull(); }); diff --git a/frontend/dashboard/src/app/core/utils/oauth-display.ts b/frontend/dashboard/src/app/core/utils/oauth-display.ts index a73cc655..109e3eb6 100644 --- a/frontend/dashboard/src/app/core/utils/oauth-display.ts +++ b/frontend/dashboard/src/app/core/utils/oauth-display.ts @@ -2,7 +2,12 @@ const SCOPE_LABELS = new Map([ ['sites:read', 'oauth.scope.sitesRead'], ['analytics:read', 'oauth.scope.analyticsRead'], ['tracking_domains:read', 'oauth.scope.trackingDomainsRead'], - ['ingest:write', 'oauth.scope.ingestWrite'] + ['ingest:write', 'oauth.scope.ingestWrite'], + ['goals:write', 'oauth.scope.goalsWrite'], + ['annotations:write', 'oauth.scope.annotationsWrite'], + ['sites:write', 'oauth.scope.sitesWrite'], + ['opportunities:write', 'oauth.scope.opportunitiesWrite'], + ['billing:write', 'oauth.scope.billingWrite'] ]); // Display copy only; the backend owns scope validation and authorization. diff --git a/frontend/dashboard/src/app/pages/oauth/oauth-consent.html b/frontend/dashboard/src/app/pages/oauth/oauth-consent.html index 4f2c48f4..c0d6b731 100644 --- a/frontend/dashboard/src/app/pages/oauth/oauth-consent.html +++ b/frontend/dashboard/src/app/pages/oauth/oauth-consent.html @@ -65,9 +65,11 @@

{{ 'oauth.abilities' | tran /> @if (request.teams.length === 0) { - {{ 'oauth.noEligibleSites' | transloco }} + {{ (billingOnly() ? 'oauth.noEligibleTeams' : 'oauth.noEligibleSites') | transloco }} {{ 'oauth.setupSites' | transloco }} - } @else if (selectedTeam(); as team) { + } @else if (selectedTeam(); as team) { @if (billingOnly()) { +

{{ 'oauth.billingHelp' | transloco }}

+ } @else {
{{ 'oauth.sites' | transloco }}

{{ 'oauth.siteHelp' | transloco }}

@@ -86,7 +88,7 @@

{{ 'oauth.abilities' | tran }

- } + } }
{{ 'oauth.connectionDetails' | transloco }}
diff --git a/frontend/dashboard/src/app/pages/oauth/oauth-consent.spec.ts b/frontend/dashboard/src/app/pages/oauth/oauth-consent.spec.ts index 14d697f2..d28a2f6f 100644 --- a/frontend/dashboard/src/app/pages/oauth/oauth-consent.spec.ts +++ b/frontend/dashboard/src/app/pages/oauth/oauth-consent.spec.ts @@ -109,6 +109,38 @@ describe('OAuthConsentPage', () => { expect(payload.get('csrf_token')).toBe('csrf-binding'); }); + it('authorizes team-only billing access without inventing a site selection', async () => { + oauth.consent.mockReturnValue(of({ ...preview(), scopes: ['billing:write'], teams: [{ id: 'team-1', name: 'Billing team', sites: [] }] })); + const controls = await create(); + const approve = () => fixture.nativeElement.querySelector('button[value="approve"]') as HTMLButtonElement; + expect(approve().disabled).toBe(true); + controls.team_id().control().setValue('team-1'); + await fixture.whenStable(); + expect(approve().disabled).toBe(false); + expect(fixture.nativeElement.querySelector('.oauth-sites')).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('oauth.billingHelp'); + const form = fixture.nativeElement.querySelector('form') as HTMLFormElement; + const event = new SubmitEvent('submit', { submitter: approve(), bubbles: true, cancelable: true }); + form.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + const payload = new FormData(form); + expect(payload.get('team_id')).toBe('team-1'); + expect(payload.getAll('site_ids')).toEqual([]); + expect(payload.get('decision')).toBe('approve'); + }); + + it('still requires explicit sites when billing and site permissions are requested together', async () => { + oauth.consent.mockReturnValue(of({ ...preview(), scopes: ['billing:write', 'analytics:read'] })); + const controls = await create(); + controls.team_id().control().setValue('team-1'); + await fixture.whenStable(); + const approve = () => fixture.nativeElement.querySelector('button[value="approve"]') as HTMLButtonElement; + expect(approve().disabled).toBe(true); + controls.site_ids().control().setValue(['site-1']); + await fixture.whenStable(); + expect(approve().disabled).toBe(false); + }); + it('does not expose an approval form for expired requests', async () => { oauth.consent.mockReturnValue(of({ ...preview(), expires_at: new Date(Date.now() - 1000).toISOString() })); await create(); diff --git a/frontend/dashboard/src/app/pages/oauth/oauth-consent.ts b/frontend/dashboard/src/app/pages/oauth/oauth-consent.ts index 388aaa82..69712812 100644 --- a/frontend/dashboard/src/app/pages/oauth/oauth-consent.ts +++ b/frontend/dashboard/src/app/pages/oauth/oauth-consent.ts @@ -62,7 +62,11 @@ export class OAuthConsentPage { return !!consent && !(Date.parse(consent.expires_at) > this.now()); }); protected readonly expiryLabel = computed(() => formatOAuthTime(this.consent()?.expires_at ?? '', this.language()) ?? ''); - protected readonly canApprove = computed(() => !!this.selectedTeam() && this.selectedSiteIds().length > 0 && !this.expired() && !this.submitting()); + protected readonly billingOnly = computed(() => { + const scopes = this.consent()?.scopes ?? []; + return scopes.length > 0 && scopes.every((scope) => scope === 'billing:write'); + }); + protected readonly canApprove = computed(() => !!this.selectedTeam() && (this.billingOnly() || this.selectedSiteIds().length > 0) && !this.expired() && !this.submitting()); constructor() { this.form diff --git a/oauth/actions_test.go b/oauth/actions_test.go new file mode 100644 index 00000000..8ab69392 --- /dev/null +++ b/oauth/actions_test.go @@ -0,0 +1,50 @@ +package oauth + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json/v2" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "testing" +) + +func TestOAuthActionScopesRequireAIAndNeverReachMCP(t *testing.T) { + f := newProviderFixture(t) + enabled := false + f.options.AgentActionsEnabled = func() bool { return enabled } + var err error + f.p, err = New(f.store, f.options) + if err != nil { + t.Fatal(err) + } + for _, on := range []bool{false, true} { + enabled = on + w := httptest.NewRecorder() + f.p.Metadata(w, httptest.NewRequest("GET", "/metadata", nil)) + var metadata struct { + Scopes []string `json:"scopes_supported"` + } + if err := json.Unmarshal(w.Body.Bytes(), &metadata); err != nil { + t.Fatal(err) + } + if slices.Contains(metadata.Scopes, ScopeAnnotationsWrite) != on { + t.Fatalf("AI=%v scope metadata=%v", on, metadata.Scopes) + } + for _, resource := range []string{f.p.Resource("api"), f.p.Resource("mcp")} { + verifier := strings.Repeat("v", 64) + challenge := sha256.Sum256([]byte(verifier)) + q := url.Values{"client_id": {"test-client"}, "response_type": {"code"}, "resource": {resource}, "scope": {"sites:read analytics:read annotations:write"}, "code_challenge_method": {"S256"}, "code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])}} + w := httptest.NewRecorder() + f.p.Authorize(w, httptest.NewRequest(http.MethodGet, f.p.Issuer()+"/oauth/authorize?"+q.Encode(), nil)) + location := w.Header().Get("Location") + valid := on && resource == f.p.Resource("api") + if (location == f.p.Issuer()+"/oauth/consent") != valid { + t.Fatalf("AI=%v resource=%s unexpected consent=%s", on, resource, location) + } + } + } +} diff --git a/oauth/consent.go b/oauth/consent.go index 92453af6..ca382570 100644 --- a/oauth/consent.go +++ b/oauth/consent.go @@ -93,7 +93,7 @@ func (p *Provider) Authorize(w http.ResponseWriter, r *http.Request) { fail("invalid_request") return } - if len(scopes) == 0 || len(scopes) > 4 { + if len(scopes) == 0 || len(scopes) > len(allowed) { fail("invalid_scope") return } @@ -279,7 +279,8 @@ func (p *Provider) CompleteConsent(w http.ResponseWriter, r *http.Request, userI if decision == "approve" && pending.Client.RegistrationTeamID != uuid.Nil() && pending.Client.RegistrationTeamID != teamID { return ErrConsentForbidden } - if decision == "approve" && (teamID == uuid.Nil() || len(sites) == 0 || authorize == nil) { + teamOnly := len(pending.Scopes) == 1 && pending.Scopes[0] == ScopeBillingWrite + if decision == "approve" && (teamID == uuid.Nil() || (!teamOnly && len(sites) == 0) || authorize == nil) { return ErrInvalidConsent } grant := database.OAuthGrant{ID: uuid.NewV7(), UserID: userID, TeamID: teamID, SiteIDs: slices.Clone(sites), ClientRevision: pending.Client.RegistrationVersion, ClientID: pending.Client.ID, ClientName: pending.Client.Name, ClientURI: pending.Client.URI, Audience: pending.Resource, Scopes: slices.Clone(pending.Scopes), CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(grantLifetime)} @@ -311,6 +312,14 @@ func (p *Provider) CompleteConsent(w http.ResponseWriter, r *http.Request, userI return nil } seen := make(map[uuid.UUID]bool, len(sites)) + if teamOnly { + if len(sites) != 0 { + return ErrInvalidConsent + } + if err := authorize(ctx, principal, uuid.Nil()); err != nil { + return err + } + } for _, siteID := range sites { if siteID == uuid.Nil() || seen[siteID] { return ErrInvalidConsent diff --git a/oauth/provider.go b/oauth/provider.go index d1c00526..6ce41c21 100644 --- a/oauth/provider.go +++ b/oauth/provider.go @@ -28,6 +28,11 @@ const ( ScopeAnalyticsRead = "analytics:read" ScopeTrackingDomainsRead = "tracking_domains:read" ScopeIngestWrite = "ingest:write" + ScopeGoalsWrite = "goals:write" + ScopeAnnotationsWrite = "annotations:write" + ScopeSitesWrite = "sites:write" + ScopeOpportunitiesWrite = "opportunities:write" + ScopeBillingWrite = "billing:write" codeLifetime = time.Minute accessLifetime = 5 * time.Minute refreshLifetime = 30 * 24 * time.Hour @@ -40,25 +45,36 @@ var ErrInvalidToken = errors.New("invalid OAuth credential") var ErrInvalidConsent = errors.New("invalid OAuth consent") var ErrConsentForbidden = errors.New("OAuth consent forbidden") +func IsActionScope(scope string) bool { + switch scope { + case ScopeGoalsWrite, ScopeAnnotationsWrite, ScopeSitesWrite, ScopeOpportunitiesWrite, ScopeBillingWrite: + return true + default: + return false + } +} + type Options struct { - Issuer string - MCPPath string - MCPEnabled bool - BrowserSigningKey []byte - Clients []ClientMetadata - IsLeader func() bool + Issuer string + MCPPath string + MCPEnabled bool + BrowserSigningKey []byte + Clients []ClientMetadata + IsLeader func() bool + AgentActionsEnabled func() bool } type Provider struct { - store *database.Store - urls issuerURLs - clients *clientResolver - browserKey []byte - isLeader func() bool - mcpEnabled bool - core fosite.OAuth2Provider - storage *fositeStorage - strategy opaqueStrategy - mu sync.Mutex + store *database.Store + urls issuerURLs + clients *clientResolver + browserKey []byte + isLeader func() bool + mcpEnabled bool + agentActionsEnabled func() bool + core fosite.OAuth2Provider + storage *fositeStorage + strategy opaqueStrategy + mu sync.Mutex } func New(store *database.Store, options Options) (*Provider, error) { @@ -76,6 +92,7 @@ func New(store *database.Store, options Options) (*Provider, error) { key := hmac.New(sha256.New, options.BrowserSigningKey) key.Write([]byte("hitkeep/oauth/browser/v1\x00" + urls.Issuer)) p := &Provider{mcpEnabled: options.MCPEnabled, store: store, urls: urls, clients: clients, browserKey: key.Sum(nil), isLeader: options.IsLeader, strategy: opaqueStrategy{}} + p.agentActionsEnabled = options.AgentActionsEnabled storage := &fositeStorage{provider: p} p.storage = storage config := &fosite.Config{AuthorizeCodeLifespan: codeLifetime, AccessTokenLifespan: accessLifetime, RefreshTokenLifespan: refreshLifetime, EnforcePKCE: true, EnforcePKCEForPublicClients: true, EnablePKCEPlainChallengeMethod: false, ScopeStrategy: fosite.ExactScopeStrategy, AudienceMatchingStrategy: fosite.ExactAudienceMatchingStrategy, RefreshTokenScopes: []string{}, SendDebugMessagesToClients: false, TokenURL: urls.Issuer + "/oauth/token"} @@ -121,7 +138,11 @@ func (p *Provider) DiscoveryPaths() []string { func (p *Provider) Ready() bool { return p != nil && p.isLeader != nil && p.isLeader() } func scopesForResource(p *Provider, resource string) []string { if resource == p.urls.APIResource { - return []string{ScopeSitesRead, ScopeAnalyticsRead, ScopeTrackingDomainsRead, ScopeIngestWrite} + scopes := []string{ScopeSitesRead, ScopeAnalyticsRead, ScopeTrackingDomainsRead, ScopeIngestWrite} + if p.agentActionsEnabled != nil && p.agentActionsEnabled() { + scopes = append(scopes, ScopeGoalsWrite, ScopeAnnotationsWrite, ScopeSitesWrite, ScopeOpportunitiesWrite, ScopeBillingWrite) + } + return scopes } if p.mcpEnabled && resource == p.urls.MCPResource { return []string{ScopeSitesRead, ScopeAnalyticsRead} diff --git a/server/a2a.go b/server/a2a.go new file mode 100644 index 00000000..600897ec --- /dev/null +++ b/server/a2a.go @@ -0,0 +1,217 @@ +package server + +import ( + "context" + "encoding/json/v2" + "errors" + "net/http" + "net/url" + "slices" + "strings" + "time" + "uuid" + + legacyuuid "github.com/google/uuid" + + "hitkeep/analyticstools" + "hitkeep/appurl" + "hitkeep/auth" + wire "hitkeep/jsonapi" + "hitkeep/oauth" + "hitkeep/server/shared" +) + +var errA2AAnalyticsUnavailable = errors.New("analytics unavailable") + +func (s *Server) registerA2ARoutes(mux *http.ServeMux) { + authenticated := func(w http.ResponseWriter, r *http.Request) { + parts := strings.Fields(r.Header.Get("Authorization")) + challenge := `Bearer resource_metadata="` + s.ctx.OAuthProvider.ResourceMetadataURL("api") + `"` + if len(r.Header.Values("Authorization")) != 1 || len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || !oauth.IsOAuthToken(parts[1]) { + w.Header().Set("WWW-Authenticate", challenge) + a2aError(w, 401, "UNAUTHENTICATED", "API-resource OAuth token required", "") + return + } + grant, err := s.ctx.OAuthProvider.Authenticate(r.Context(), parts[1], s.ctx.OAuthProvider.Resource("api")) + if errors.Is(err, oauth.ErrUnavailable) { + a2aError(w, 503, "UNAVAILABLE", "OAuth temporarily unavailable", "") + return + } + if err != nil || grant == nil { + w.Header().Set("WWW-Authenticate", challenge) + a2aError(w, 401, "UNAUTHENTICATED", "Invalid OAuth token", "") + return + } + if !slices.Contains(grant.Scopes, oauth.ScopeSitesRead) || !slices.Contains(grant.Scopes, oauth.ScopeAnalyticsRead) { + w.Header().Set("WWW-Authenticate", `Bearer error="insufficient_scope", scope="sites:read analytics:read"`) + a2aError(w, 403, "PERMISSION_DENIED", "Required scopes: sites:read analytics:read", "") + return + } + if r.URL.Path != "/api/a2a/message:send" || r.Method != http.MethodPost { + if strings.HasPrefix(r.URL.Path, "/api/a2a/tasks") { + a2aError(w, 404, "NOT_FOUND", "This synchronous agent has no tasks", "TASK_NOT_FOUND") + } else { + a2aError(w, 400, "FAILED_PRECONDITION", "Only synchronous SendMessage is supported", "UNSUPPORTED_OPERATION") + } + return + } + serveA2AMessage(w, r, s.a2aScope(grant), analyticstools.Analytics()) + } + handler := s.ctx.Handler(shared.HandlerConfig{RateLimiter: s.ctx.ApiLimiter}, func(w http.ResponseWriter, r *http.Request) { + if !s.ctx.A2AAvailable() { + http.NotFound(w, r) + return + } + authenticated(w, r) + }) + mux.HandleFunc("/api/a2a/", handler) +} + +func (s *Server) a2aScope(grant *auth.OAuthGrant) analyticstools.Scope { + return analyticstools.Scope{MaxRangeDays: 31, Resolve: func(ctx context.Context, siteID legacyuuid.UUID) (analyticstools.Site, error) { + if !s.ctx.A2AAvailable() || grant == nil || !slices.Contains(grant.Scopes, oauth.ScopeSitesRead) { + return analyticstools.Site{}, shared.ErrOAuthAccessDenied + } + if err := s.ctx.AuthorizeOAuthSite(ctx, grant, uuid.UUID(siteID), s.ctx.OAuthProvider.Resource("api"), oauth.ScopeAnalyticsRead, auth.PermSiteView); err != nil { + return analyticstools.Site{}, shared.ErrOAuthAccessDenied + } + store, err := s.ctx.AnalyticsStore(ctx, siteID) + if err != nil { + return analyticstools.Site{}, errA2AAnalyticsUnavailable + } + return analyticstools.Site{ID: siteID, UserID: legacyuuid.UUID(grant.UserID), Control: s.ctx.Store, Analytics: store}, nil + }} +} + +type a2aPart struct { + MediaType string `json:"mediaType"` + Metadata map[string]wire.RawMessage `json:"metadata"` + Data *struct { + Tool string `json:"tool"` + Arguments wire.RawMessage `json:"arguments"` + } `json:"data"` +} + +type a2aRequest struct { + Tenant string `json:"tenant"` + Metadata map[string]wire.RawMessage `json:"metadata"` + Message struct { + MessageID string `json:"messageId"` + Metadata map[string]wire.RawMessage `json:"metadata"` + Extensions []string `json:"extensions"` + ReferenceTaskIDs []string `json:"referenceTaskIds"` + ContextID string `json:"contextId"` + TaskID string `json:"taskId"` + Role string `json:"role"` + Parts []a2aPart `json:"parts"` + } `json:"message"` + Configuration struct { + AcceptedOutputModes []string `json:"acceptedOutputModes"` + PushNotificationConfig wire.RawMessage `json:"taskPushNotificationConfig"` + HistoryLength int `json:"historyLength"` + ReturnImmediately bool `json:"returnImmediately"` + } `json:"configuration"` +} + +func serveA2AMessage(w http.ResponseWriter, r *http.Request, scope analyticstools.Scope, tools []analyticstools.Tool) { + version := r.Header.Get("A2a-Version") + if version == "" { + version = r.URL.Query().Get("A2A-Version") + } + if version != "1.0" { + a2aError(w, 400, "INVALID_ARGUMENT", "Supported A2A version: 1.0", "VERSION_NOT_SUPPORTED") + return + } + var request a2aRequest + if err := wire.UnmarshalRead(http.MaxBytesReader(w, r.Body, 64<<10), &request, json.RejectUnknownMembers(true)); err != nil { + a2aError(w, 400, "INVALID_ARGUMENT", "Expected one structured analytics message", "") + return + } + if request.Tenant != "" { + a2aError(w, 400, "INVALID_ARGUMENT", "Tenant routing is not supported", "") + return + } + if len(request.Configuration.PushNotificationConfig) > 0 && string(request.Configuration.PushNotificationConfig) != "null" { + a2aError(w, 400, "INVALID_ARGUMENT", "Push notifications are unavailable", "PUSH_NOTIFICATION_NOT_SUPPORTED") + return + } + message := request.Message + if len(message.ReferenceTaskIDs) > 0 { + a2aError(w, 404, "NOT_FOUND", "This synchronous agent has no tasks", "TASK_NOT_FOUND") + return + } + if message.TaskID != "" { + a2aError(w, 404, "NOT_FOUND", "This synchronous agent has no tasks", "TASK_NOT_FOUND") + return + } + if len(request.Configuration.AcceptedOutputModes) > 0 && !slices.Contains(request.Configuration.AcceptedOutputModes, "application/json") { + a2aError(w, 400, "INVALID_ARGUMENT", "Output mode must accept application/json", "CONTENT_TYPE_NOT_SUPPORTED") + return + } + if message.MessageID == "" || len(message.MessageID) > 256 || len(message.ContextID) > 256 || message.Role != "ROLE_USER" || len(message.Parts) != 1 || message.Parts[0].Data == nil { + a2aError(w, 400, "INVALID_ARGUMENT", "Send one data part with tool and arguments", "") + return + } + data := message.Parts[0].Data + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + for _, definition := range tools { + if definition.Name != data.Tool { + continue + } + tool := analyticstools.GoAI(scope, definition)[0] + output, err := tool.Execute(ctx, data.Arguments) + if err != nil { + if errors.Is(err, shared.ErrOAuthAccessDenied) { + a2aError(w, 403, "PERMISSION_DENIED", "Site access denied", "") + return + } + a2aError(w, 400, "INVALID_ARGUMENT", err.Error(), "") + return + } + contextID := message.ContextID + if contextID == "" { + contextID = uuid.New().String() + } + a2aResponse(w, 200, map[string]any{"message": map[string]any{"messageId": uuid.New().String(), "contextId": contextID, "role": "ROLE_AGENT", "parts": []any{map[string]any{"data": wire.RawMessage(output), "mediaType": "application/json"}}}}) + return + } + a2aError(w, 400, "INVALID_ARGUMENT", "Unknown analytics tool", "") +} + +func a2aResponse(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/a2a+json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + _ = wire.MarshalWrite(w, value) +} + +func a2aError(w http.ResponseWriter, code int, status, message, reason string) { + body := map[string]any{"code": code, "status": status, "message": message} + if reason != "" { + body["details"] = []any{map[string]string{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": reason, "domain": "a2a-protocol.org"}} + } + a2aResponse(w, code, map[string]any{"error": body}) +} + +func (s *Server) a2aCard(base *url.URL) any { + if !s.ctx.A2AAvailable() { + return nil + } + skills := []map[string]any{} + tools := analyticstools.Analytics() + adapted := analyticstools.GoAI(analyticstools.Scope{MaxRangeDays: 31}, tools...) + for i, tool := range tools { + skills = append(skills, map[string]any{"id": tool.Name, "name": tool.Title, "description": tool.Description + " Send one data part: {tool: skill id, arguments: tool input including site_id}. Maximum range 31 days. Input JSON Schema: " + string(adapted[i].InputSchema), "tags": []string{"analytics", "read-only"}}) + } + return map[string]any{ + "name": "HitKeep Analytics", "description": "Synchronous structured read-only aggregate analytics for explicitly consented sites. No natural-language model, tasks, streaming, or push notifications.", "version": s.conf.Version, + "supportedInterfaces": []any{map[string]string{"url": appurl.Path(base.String(), "/api/a2a"), "protocolBinding": "HTTP+JSON", "protocolVersion": "1.0"}}, + "capabilities": map[string]bool{"streaming": false, "pushNotifications": false}, + "defaultInputModes": []string{"application/json"}, "defaultOutputModes": []string{"application/json"}, "skills": skills, + "securitySchemes": map[string]any{"oauth": map[string]any{"oauth2SecurityScheme": map[string]any{"oauth2MetadataUrl": base.Scheme + "://" + base.Host + s.ctx.OAuthProvider.DiscoveryPaths()[0], "flows": map[string]any{"authorizationCode": map[string]any{"authorizationUrl": appurl.Path(base.String(), "/oauth/authorize"), "tokenUrl": appurl.Path(base.String(), "/oauth/token"), "pkceRequired": true, "scopes": map[string]string{"sites:read": "Consented sites", "analytics:read": "Aggregate analytics"}}}}}}, + "securityRequirements": []any{map[string]any{"schemes": map[string]any{"oauth": map[string]any{"list": []string{"sites:read", "analytics:read"}}}}}, + "documentationUrl": appurl.Path(base.String(), "/auth.md"), + } +} diff --git a/server/a2a_test.go b/server/a2a_test.go new file mode 100644 index 00000000..12a3812d --- /dev/null +++ b/server/a2a_test.go @@ -0,0 +1,182 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + stdUUID "uuid" + + "github.com/google/uuid" + "hitkeep/analyticstools" + "hitkeep/auth" + "hitkeep/database" + json "hitkeep/jsonapi" + "hitkeep/oauth" + "hitkeep/server/shared" +) + +func TestA2ASynchronousRegistryExecutionAndDenial(t *testing.T) { + siteID := uuid.New() + scope := analyticstools.Scope{MaxRangeDays: 31, Resolve: func(context.Context, uuid.UUID) (analyticstools.Site, error) { + return analyticstools.Site{}, shared.ErrOAuthAccessDenied + }} + for _, tc := range []struct { + name, body, version string + want int + }{ + {"version", `{}`, "0.3", 400}, + {"missing version", `{}`, "", 400}, + {"oversized", strings.Repeat(" ", 64<<10) + `{}`, "1.0", 400}, + {"duplicate member", `{"message":{},"message":{}}`, "1.0", 400}, + {"unsupported task", `{"message":{"taskId":"unknown"}}`, "1.0", 404}, + {"unsupported push", `{"configuration":{"taskPushNotificationConfig":{"url":"https://untrusted.example/"}}}`, "1.0", 400}, + {"unsupported output", `{"configuration":{"acceptedOutputModes":["text/plain"]}}`, "1.0", 400}, + {"bounded range", `{"message":{"messageId":"1","role":"ROLE_USER","parts":[{"data":{"tool":"hitkeep_get_site_overview","arguments":{"site_id":"` + siteID.String() + `","from":"2020-01-01T00:00:00Z","to":"2021-01-01T00:00:00Z"}}}]}}`, "1.0", 400}, + {"malformed", `{"message":`, "1.0", 400}, + {"text", `{"message":{"messageId":"1","role":"ROLE_USER","parts":[{"text":"hello"}]}}`, "1.0", 400}, + {"unknown tool", `{"message":{"messageId":"1","role":"ROLE_USER","parts":[{"data":{"tool":"delete_site","arguments":{}}}]}}`, "1.0", 400}, + {"denied site", `{"message":{"messageId":"1","role":"ROLE_USER","parts":[{"data":{"tool":"hitkeep_get_event_names","arguments":{"site_id":"` + siteID.String() + `"}}}]}}`, "1.0", 403}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/a2a/message:send", strings.NewReader(tc.body)) + req.Header.Set("A2A-Version", tc.version) + w := httptest.NewRecorder() + serveA2AMessage(w, req, scope, analyticstools.Analytics()) + if w.Code != tc.want { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), `"role":"ROLE_AGENT"`) { + t.Fatal("denied request returned analytics") + } + }) + } + tool := analyticstools.Define("test_read", "Read test aggregate", "Read test aggregate", func(context.Context, analyticstools.Call, struct{}) (map[string]int, error) { + return map[string]int{"count": 42}, nil + }) + req := httptest.NewRequest("POST", "/api/a2a/message:send", strings.NewReader(`{"message":{"messageId":"1","role":"ROLE_USER","parts":[{"data":{"tool":"test_read","arguments":{}}}]}}`)) + req.Header.Set("A2A-Version", "1.0") + w := httptest.NewRecorder() + serveA2AMessage(w, req, scope, []analyticstools.Tool{tool}) + if w.Code != 200 || !strings.Contains(w.Body.String(), `"count":42`) || !strings.Contains(w.Body.String(), `"role":"ROLE_AGENT"`) { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } +} + +func TestA2AFlagAndOAuthBoundary(t *testing.T) { + s := newDiscoveryServer() + s.ctx.Store = testServerStore(t) + s.ctx.TenantStores = database.NewTenantStoreManager(s.ctx.Store, t.TempDir(), database.WithTenantDataPlane(false)) + t.Cleanup(func() { _ = s.ctx.Store.Close() }) + provider, err := oauth.New(s.ctx.Store, oauth.Options{Issuer: s.conf.PublicURL, MCPPath: "/mcp", BrowserSigningKey: []byte(strings.Repeat("s", 32)), IsLeader: func() bool { return true }}) + if err != nil { + t.Fatal(err) + } + s.ctx.OAuthProvider = provider + mux := http.NewServeMux() + s.registerA2ARoutes(mux) + for _, enabled := range []bool{false, true} { + s.conf.AIEnabled = enabled + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/api/a2a/message:send", strings.NewReader(`{}`)) + req.AddCookie(&http.Cookie{Name: "session", Value: "cookie-is-not-oauth"}) + mux.ServeHTTP(w, req) + want := 404 + if enabled { + want = 401 + } + if w.Code != want { + t.Fatalf("AI=%v status=%d", enabled, w.Code) + } + } + grant := &auth.OAuthGrant{} + scope := s.a2aScope(grant) + if _, err := scope.Resolve(t.Context(), uuid.New()); err == nil { + t.Fatal("unconsented site resolved") + } +} + +func TestRawAIStatusIndependentOfAskAI(t *testing.T) { + s := newDiscoveryServer() + s.ctx.Store = testServerStore(t) + s.ctx.TenantStores = database.NewTenantStoreManager(s.ctx.Store, t.TempDir(), database.WithTenantDataPlane(false)) + t.Cleanup(func() { _ = s.ctx.Store.Close() }) + for _, enabled := range []bool{false, true} { + s.conf.AIEnabled = enabled + status, err := s.ctx.SystemStatusResponse(t.Context()) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(status) + if err != nil { + t.Fatal(err) + } + if status.AIEnabled != enabled || status.AskAI.Enabled || !strings.Contains(string(raw), `"ai_enabled":`) { + t.Fatalf("status %s", raw) + } + } +} + +func TestA2ARealAnalyticsConsentAndAuthority(t *testing.T) { + s := newDiscoveryServer() + s.ctx.Store = testServerStore(t) + s.ctx.TenantStores = database.NewTenantStoreManager(s.ctx.Store, t.TempDir(), database.WithTenantDataPlane(false)) + t.Cleanup(func() { _ = s.ctx.Store.Close() }) + user, err := s.ctx.Store.CreateUser(t.Context(), "a2a@example.test", "unused-hash") + if err != nil { + t.Fatal(err) + } + site, err := s.ctx.Store.CreateSite(t.Context(), user, "a2a.example.test") + if err != nil { + t.Fatal(err) + } + team, err := s.ctx.Store.GetSiteTenantID(t.Context(), site.ID) + if err != nil { + t.Fatal(err) + } + provider, err := oauth.New(s.ctx.Store, oauth.Options{Issuer: s.conf.PublicURL, MCPPath: "/mcp", BrowserSigningKey: []byte(strings.Repeat("s", 32)), IsLeader: func() bool { return true }}) + if err != nil { + t.Fatal(err) + } + s.ctx.OAuthProvider = provider + grant := auth.OAuthGrant{ID: stdUUID.New(), UserID: stdUUID.UUID(user), TeamID: stdUUID.UUID(team), SiteIDs: []stdUUID.UUID{stdUUID.UUID(site.ID)}, Scopes: []string{oauth.ScopeSitesRead, oauth.ScopeAnalyticsRead}, Audience: provider.Resource("api")} + for _, tc := range []struct { + name string + change func(*auth.OAuthGrant) + want int + }{ + {"allowed", func(*auth.OAuthGrant) {}, 200}, + {"wrong audience", func(g *auth.OAuthGrant) { g.Audience = provider.Resource("mcp") }, 403}, + {"unconsented site", func(g *auth.OAuthGrant) { g.SiteIDs = nil }, 403}, + {"missing scope", func(g *auth.OAuthGrant) { g.Scopes = []string{oauth.ScopeSitesRead} }, 403}, + {"wrong team", func(g *auth.OAuthGrant) { g.TeamID = stdUUID.New() }, 403}, + } { + t.Run(tc.name, func(t *testing.T) { + g := grant + tc.change(&g) + body := `{"message":{"messageId":"1","role":"ROLE_USER","parts":[{"data":{"tool":"hitkeep_get_site_overview","arguments":{"site_id":"` + site.ID.String() + `","sections":["kpis"]}}}]}}` + req := httptest.NewRequest("POST", "/api/a2a/message:send", strings.NewReader(body)) + req.Header.Set("A2A-Version", "1.0") + w := httptest.NewRecorder() + serveA2AMessage(w, req, s.a2aScope(&g), analyticstools.Analytics()) + if w.Code != tc.want { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + if tc.want == 200 && !strings.Contains(w.Body.String(), `"total_pageviews":0`) { + t.Fatalf("no real aggregate: %s", w.Body.String()) + } + }) + } + for _, enabled := range []bool{true, false} { + s.conf.AIEnabled = enabled + card := discoveryRequest(s, "GET", "/.well-known/agent-card.json") + want := 404 + if enabled { + want = 200 + } + if card.Code != want { + t.Fatalf("AI=%v card=%d %s", enabled, card.Code, card.Body.String()) + } + } +} diff --git a/server/agent_request.go b/server/agent_request.go new file mode 100644 index 00000000..3ad25989 --- /dev/null +++ b/server/agent_request.go @@ -0,0 +1,15 @@ +package server + +import "net/http" + +// The marker can only restrict existing handlers; authentication still belongs to each route. +func (s *Server) agentRequestMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if len(r.Header.Values("X-Hitkeep-Agent")) > 0 && !s.agentAvailable() { + w.Header().Set("Cache-Control", "no-store") + http.NotFound(w, r) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/server/agent_request_test.go b/server/agent_request_test.go new file mode 100644 index 00000000..9dd84f39 --- /dev/null +++ b/server/agent_request_test.go @@ -0,0 +1,39 @@ +package server + +import ( + "hitkeep/server/shared" + "net/http" + "net/http/httptest" + "testing" +) + +func TestAgentRequestMarkerOnlyRestricts(t *testing.T) { + s := newDiscoveryServer() + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + for _, enabled := range []bool{false, true} { + s.conf.AIEnabled = enabled + for _, marked := range []bool{false, true} { + r := httptest.NewRequest("POST", "/api/sites", nil) + if marked { + r.Header.Set("X-HitKeep-Agent", "webmcp") + } + w := httptest.NewRecorder() + s.agentRequestMiddleware(next).ServeHTTP(w, r) + want := 204 + if marked && !enabled { + want = 404 + } + if w.Code != want { + t.Fatalf("AI=%v marker=%v got %d", enabled, marked, w.Code) + } + } + } + r := httptest.NewRequest("POST", "/api/sites", nil) + r.Header.Set("X-HitKeep-Agent", "webmcp") + protected := s.ctx.Handler(shared.HandlerConfig{RequireAuth: true}, next.ServeHTTP) + w := httptest.NewRecorder() + s.agentRequestMiddleware(protected).ServeHTTP(w, r) + if w.Code != 401 { + t.Fatalf("marker granted authority: %d", w.Code) + } +} diff --git a/server/annotations/handlers.go b/server/annotations/handlers.go index 9bc98c74..10eb240e 100644 --- a/server/annotations/handlers.go +++ b/server/annotations/handlers.go @@ -31,18 +31,22 @@ type handler struct { func Register(mux *http.ServeMux, ctx *shared.Context) { h := &handler{ctx: ctx} mux.HandleFunc("GET /api/sites/{id}/annotations", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleList())) mux.HandleFunc("POST /api/sites/{id}/annotations", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "annotations:write", SitePerm: authcore.PermSiteManageAnnotations, RateLimiter: ctx.ApiLimiter, }, h.handleCreate())) mux.HandleFunc("PUT /api/sites/{id}/annotations/{annotationID}", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "annotations:write", SitePerm: authcore.PermSiteManageAnnotations, RateLimiter: ctx.ApiLimiter, }, h.handleUpdate())) mux.HandleFunc("DELETE /api/sites/{id}/annotations/{annotationID}", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "annotations:write", SitePerm: authcore.PermSiteManageAnnotations, RateLimiter: ctx.ApiLimiter, }, h.handleDelete())) diff --git a/server/cloud/commerce_billing.go b/server/cloud/commerce_billing.go new file mode 100644 index 00000000..15b03b49 --- /dev/null +++ b/server/cloud/commerce_billing.go @@ -0,0 +1,343 @@ +//go:build billing + +package cloud + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + + "hitkeep/appurl" + "hitkeep/auth" + "hitkeep/config" + "hitkeep/database" + json "hitkeep/jsonapi" + "hitkeep/oauth" + "hitkeep/server/shared" +) + +const acpVersion = "2026-04-17" +const ucpVersion = "2026-08-25" + +// CommerceAvailable reports the configured human-approved Checkout adapter, not a payment rail. +func CommerceAvailable(c *config.Config) bool { + return c != nil && c.AIEnabled && c.CloudHosted && strings.TrimSpace(c.StripeSecretKey) != "" && (c.StripePriceProMonthly != "" || c.StripePriceProAnnual != "" || c.StripePriceBusinessMonthly != "" || c.StripePriceBusinessAnnual != "") +} + +func (h *handler) registerCommerce(mux *http.ServeMux) { + access := shared.HandlerConfig{RequireAuth: true, HumanOnly: true, OAuthScope: oauth.ScopeBillingWrite, ActiveTeamCap: auth.CapTeamManageSettings, RateLimiter: h.ctx.ApiLimiter} + for _, protocol := range []string{"acp", "ucp"} { + path := "/api/cloud/commerce/" + protocol + "/checkout_sessions" + update := "POST" + if protocol == "ucp" { + path = "/api/cloud/commerce/ucp/checkout-sessions" + update = "PUT" + } + for _, route := range []struct{ pattern, action string }{{"POST " + path, "create"}, {"GET " + path + "/{id}", "get"}, {update + " " + path + "/{id}", "update"}, {"POST " + path + "/{id}/complete", "complete"}, {"POST " + path + "/{id}/cancel", "cancel"}} { + mux.HandleFunc(route.pattern, h.commerceGate(h.ctx.Handler(access, h.handleCommerceSession(protocol, route.action)))) + } + } + for _, rail := range []string{"mpp", "x402"} { + mux.HandleFunc("POST /api/cloud/commerce/"+rail+"/purchases/{id}/pay", h.commerceGate(h.ctx.Handler(access, func(w http.ResponseWriter, r *http.Request) { + shared.WriteJSON(r.Context(), w, http.StatusNotImplemented, map[string]any{"error": "payment_rail_unavailable", "protocol": rail, "reason": "This build has no native payment-proof and settlement adapter for this rail. Merchant payment configuration is also absent. Use the approved Checkout flow."}) + }))) + } +} + +func (h *handler) commerceGate(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "private, no-store") + if !h.ctx.Config.AIEnabled || !h.ctx.Config.CloudHosted { + http.NotFound(w, r) + return + } + if h.ctx.Store == nil || (h.ctx.Cluster != nil && !h.ctx.Cluster.IsLeader()) { + http.Error(w, "Service not available on this node", http.StatusServiceUnavailable) + return + } + if !CommerceAvailable(h.ctx.Config) || h.stripe == nil { + http.Error(w, "Checkout is not configured", http.StatusServiceUnavailable) + return + } + next(w, r) + } +} + +type commerceRequest struct { + LineItems []struct { + ID string `json:"id"` + Item struct { + ID string `json:"id"` + } `json:"item"` + Quantity *int `json:"quantity"` + } `json:"line_items"` + UCP struct { + Version string `json:"version"` + Capabilities map[string]any `json:"capabilities"` + } `json:"ucp"` +} + +func (h *handler) handleCommerceSession(protocol, action string) http.HandlerFunc { + return h.commerceGate(func(w http.ResponseWriter, r *http.Request) { + if r.Context().Value(shared.APIClientAuthKey) != nil { + http.Error(w, "User-delegated billing authorization required", http.StatusUnauthorized) + return + } + userID := shared.GetUserIDFromContext(r) + if userID == uuid.Nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + if protocol == "acp" && r.Header.Get("API-Version") != acpVersion { + http.Error(w, "Unsupported API-Version", http.StatusBadRequest) + return + } + if protocol == "ucp" { + if r.Header.Get("Request-Id") == "" || len(r.Header.Get("Request-Id")) > 255 { + http.Error(w, "Request-Id required", http.StatusBadRequest) + return + } + w.Header().Set("Request-Id", r.Header.Get("Request-Id")) + check := h.profileCheck + if check == nil { + check = fetchCommerceProfile + } + if status, err := check(r.Context(), r.Header.Get("UCP-Agent")); err != nil { + http.Error(w, err.Error(), status) + return + } + } + var req commerceRequest + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10)) + if err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + if len(body) > 0 { + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + } + if protocol == "ucp" && req.UCP.Version != "" && req.UCP.Version != ucpVersion { + http.Error(w, "Unsupported UCP version", http.StatusBadRequest) + return + } + for name := range req.UCP.Capabilities { + if name != "dev.ucp.shopping.checkout" { + http.Error(w, "Unsupported UCP capability", http.StatusBadRequest) + return + } + } + teamID, err := h.ctx.ResolveRequestTeamID(r) + if err != nil { + http.Error(w, "Unable to resolve billing team", http.StatusBadRequest) + return + } + if !shared.CheckAgentTeam(w, r, teamID) { + return + } + role, err := h.ctx.Store.GetTenantRole(r.Context(), teamID, userID) + if err != nil || !auth.TeamRoleHasCapability(role, auth.CapTeamManageSettings) { + http.Error(w, "Access denied", http.StatusForbidden) + return + } + h.commerceMu.Lock() + defer h.commerceMu.Unlock() + eventID := "" + if action != "get" { + key := r.Header.Get("Idempotency-Key") + if key == "" || len(key) > 255 { + http.Error(w, "Idempotency-Key required (maximum 255 characters)", http.StatusBadRequest) + return + } + w.Header().Set("Idempotency-Key", key) + eventID = fmt.Sprintf("agent_checkout_%x", sha256.Sum256([]byte(teamID.String()+":"+protocol+":"+action+":"+r.PathValue("id")+":"+key))) + fingerprint := fmt.Sprintf("%x", sha256.Sum256(body)) + inserted, err := h.ctx.Store.CreateCloudBillingEvent(r.Context(), database.CloudBillingEvent{StripeEventID: eventID, TenantID: teamID, EventType: "agent.checkout.request", Payload: fingerprint}) + if err != nil { + http.Error(w, "Unable to reserve request", http.StatusServiceUnavailable) + return + } + if !inserted { + prior, err := h.ctx.Store.GetCloudBillingEvent(r.Context(), eventID) + if err != nil { + http.Error(w, "Unable to load request", http.StatusServiceUnavailable) + return + } + if prior.Payload != fingerprint { + status := http.StatusConflict + if protocol == "acp" { + status = http.StatusUnprocessableEntity + } + http.Error(w, "Idempotency key reused with a different request", status) + return + } + result, err := h.ctx.Store.GetCloudBillingEvent(r.Context(), eventID+"_result") + if err != nil && !errors.Is(err, database.ErrCloudBillingEventNotFound) { + http.Error(w, "Unable to load request result", http.StatusServiceUnavailable) + return + } + if result != nil { + var cached checkoutSessionOutput + if err := json.Unmarshal([]byte(result.Payload), &cached); err != nil { + http.Error(w, "Unable to load request result", http.StatusServiceUnavailable) + return + } + status := http.StatusOK + if action == "create" { + status = http.StatusCreated + } + w.Header().Set("Idempotent-Replayed", "true") + shared.WriteJSON(r.Context(), w, status, h.commerceResponse(protocol, &cached)) + return + } + } + } + var session *checkoutSessionOutput + status := http.StatusOK + itemID := "" + quantity := 0 + if protocol == "acp" && len(req.LineItems) == 1 && req.LineItems[0].Quantity == nil { + itemID = req.LineItems[0].ID + quantity = 1 + } + if protocol == "ucp" && len(req.LineItems) == 1 && req.LineItems[0].Quantity != nil { + itemID = req.LineItems[0].Item.ID + quantity = *req.LineItems[0].Quantity + } + if action == "create" { + plan, interval, ok := strings.Cut(itemID, ":") + if !ok || quantity != 1 || (plan != database.CloudPlanPro && plan != database.CloudPlanBusiness) || (interval != database.CloudBillingIntervalMonthly && interval != database.CloudBillingIntervalAnnual) { + http.Error(w, "One paid plan item (pro:monthly, pro:annual, business:monthly, business:annual) is required", http.StatusBadRequest) + return + } + price, err := h.stripe.GetPrice(r.Context(), priceIDForPlan(h.ctx.Config, plan, interval)) + currency := "eur" + if strings.EqualFold(h.ctx.Config.CloudJurisdiction, "US") { + currency = "usd" + } + cadence := "month" + if interval == database.CloudBillingIntervalAnnual { + cadence = "year" + } + if err != nil || price == nil || !price.Active || price.AmountMinor <= 0 || !strings.EqualFold(price.Currency, currency) || price.Interval != cadence { + http.Error(w, "Plan price is unavailable", http.StatusConflict) + return + } + session = h.createBillingCheckout(w, r, billingCheckoutSessionRequest{teamID: teamID, PlanCode: plan, BillingInterval: interval}) + if session == nil { + return + } + status = http.StatusCreated + } else { + id := r.PathValue("id") + if !strings.HasPrefix(id, "cs_") || len(id) > 255 { + http.NotFound(w, r) + return + } + session, err = h.stripe.GetCheckoutSession(r.Context(), id) + if err != nil || session == nil { + http.Error(w, "Unable to retrieve checkout", http.StatusBadGateway) + return + } + account, err := h.ctx.Store.GetCloudBillingAccount(r.Context(), teamID) + if err != nil || account == nil || session.TenantID != teamID.String() || session.CustomerID != account.StripeCustomerID { + http.NotFound(w, r) + return + } + if action == "update" && len(req.LineItems) > 0 && (quantity != 1 || itemID != session.PlanCode+":"+session.BillingInterval) { + http.Error(w, "Cancel the existing checkout before changing the plan", http.StatusConflict) + return + } + if action == "cancel" && session.Status != "expired" { + if session.Status != "open" { + http.Error(w, "Checkout cannot be canceled", http.StatusMethodNotAllowed) + return + } + session, err = h.stripe.ExpireCheckoutSession(r.Context(), id, eventID) + if err != nil { + http.Error(w, "Unable to cancel checkout", http.StatusBadGateway) + return + } + } + } + if eventID != "" { + saved, err := json.Marshal(session) + if err != nil { + http.Error(w, "Unable to encode checkout", http.StatusInternalServerError) + return + } + if _, err := h.ctx.Store.CreateCloudBillingEvent(r.Context(), database.CloudBillingEvent{StripeEventID: eventID + "_result", TenantID: teamID, EventType: "agent.checkout.result", Payload: string(saved), ProcessingStatus: database.CloudBillingEventStatusDone}); err != nil { + http.Error(w, "Checkout result pending reconciliation; retry the same request", http.StatusServiceUnavailable) + return + } + } + shared.WriteJSON(r.Context(), w, status, h.commerceResponse(protocol, session)) + }) +} + +func commerceSessionStatus(s *checkoutSessionOutput) string { + switch s.Status { + case "complete": + if s.PaymentStatus == "paid" { + return "completed" + } + return "complete_in_progress" + case "expired": + return "canceled" + case "open": + return "requires_escalation" + default: + return "incomplete" + } +} + +func (h *handler) commerceResponse(protocol string, s *checkoutSessionOutput) map[string]any { + status := commerceSessionStatus(s) + itemID := s.PlanCode + ":" + s.BillingInterval + total := map[string]any{"type": "total", "display_text": "Total", "amount": s.AmountTotal} + line := map[string]any{"id": itemID, "item": map[string]any{"id": itemID}, "quantity": 1, "totals": []any{total}} + response := map[string]any{"id": s.ID, "status": status, "currency": s.Currency, "line_items": []any{line}, "totals": []any{total}, "messages": []any{}, "links": []any{}} + if s.ExpiresAt > 0 { + response["expires_at"] = time.Unix(s.ExpiresAt, 0).UTC().Format(time.RFC3339) + } + if protocol == "acp" { + response["protocol"] = map[string]any{"version": acpVersion} + response["capabilities"] = map[string]any{} + response["fulfillment_options"] = []any{} + } + if protocol == "ucp" { + response["currency"] = strings.ToUpper(s.Currency) + response["ucp"] = map[string]any{"version": ucpVersion, "payment_handlers": map[string]any{}, "capabilities": map[string]any{"dev.ucp.shopping.checkout": []any{map[string]any{"version": ucpVersion}}}} + line["item"] = map[string]any{"id": itemID, "title": planNameForCode(s.PlanCode) + " (" + s.BillingInterval + ")", "price": s.AmountSubtotal} + } + if status == "requires_escalation" { + response["continue_url"] = s.URL + message := map[string]any{"type": "error", "code": "requires_sign_in", "content": "A billing administrator must approve the subscription and payment in Checkout.", "content_type": "plain", "resolution": "requires_buyer_review"} + if protocol == "ucp" { + delete(message, "resolution") + delete(message, "content_type") + message["severity"] = "requires_buyer_review" + response["currency"] = strings.ToUpper(s.Currency) + } + response["messages"] = []any{message} + } + if status == "completed" { + id := s.SubscriptionID + if id == "" { + id = s.ID + } + response["order"] = map[string]any{"id": id, "checkout_session_id": s.ID, "permalink_url": appurl.Path(h.ctx.Config.PublicURL, "/admin/team")} + if protocol == "ucp" { + delete(response["order"].(map[string]any), "checkout_session_id") + } + } + return response +} diff --git a/server/cloud/commerce_billing_test.go b/server/cloud/commerce_billing_test.go new file mode 100644 index 00000000..f835b38a --- /dev/null +++ b/server/cloud/commerce_billing_test.go @@ -0,0 +1,444 @@ +//go:build billing + +package cloud + +import ( + "context" + "errors" + "github.com/google/uuid" + "net/http" + "net/http/httptest" + "strings" + "testing" + + stripe "github.com/stripe/stripe-go/v87" + + "hitkeep/auth" + "hitkeep/database" + "hitkeep/server/shared" +) + +func TestCommerceGatesBeforeProvider(t *testing.T) { + for _, gate := range []string{"ai", "cloud", "store", "provider"} { + t.Run(gate, func(t *testing.T) { + h, store := setupCloudTestHandler(t) + defer store.Close() + h.ctx.Config.AIEnabled = true + switch gate { + case "ai": + h.ctx.Config.AIEnabled = false + case "cloud": + h.ctx.Config.CloudHosted = false + case "store": + h.ctx.Store = nil + case "provider": + h.ctx.Config.StripeSecretKey = "" + } + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"line_items":[{"id":"pro:monthly"}],"currency":"eur","capabilities":{}}`)) + w := httptest.NewRecorder() + h.handleCommerceSession("acp", "create").ServeHTTP(w, req) + if w.Code < 400 { + t.Fatalf("gate %s returned %d", gate, w.Code) + } + if f := h.stripe.(*fakeStripeClient); f.checkoutCalls != 0 || f.lastCustomerInput != nil { + t.Fatal("provider mutated") + } + }) + } +} + +func TestCommerceStateRequiresProviderPayment(t *testing.T) { + for _, tc := range []struct{ status, payment, want string }{{"open", "unpaid", "requires_escalation"}, {"complete", "unpaid", "complete_in_progress"}, {"complete", "paid", "completed"}, {"expired", "unpaid", "canceled"}} { + t.Run(tc.status+tc.payment, func(t *testing.T) { + s := &checkoutSessionOutput{ID: "cs_test", URL: "https://checkout.stripe.test/session", Status: tc.status, PaymentStatus: tc.payment} + if got := commerceSessionStatus(s); got != tc.want { + t.Fatalf("status=%s want %s", got, tc.want) + } + }) + } +} + +func TestBillingCheckoutReusesPendingSession(t *testing.T) { + h, store := setupCloudTestHandler(t) + defer store.Close() + buyer, err := store.CreateManagedCloudAccount(t.Context(), database.CreateManagedCloudAccountInput{Email: "reuse@example.com", HashedPassword: "hashed", TeamName: "Reuse"}) + if err != nil { + t.Fatal(err) + } + f := h.stripe.(*fakeStripeClient) + for range 2 { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"plan_code":"pro","billing":"monthly"}`)) + req = req.WithContext(context.WithValue(req.Context(), shared.UserIDKey, buyer.UserID)) + w := httptest.NewRecorder() + h.handleCreateBillingCheckoutSession().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("checkout=%d %s", w.Code, w.Body.String()) + } + } + if f.checkoutCalls != 1 { + t.Fatalf("created %d sessions", f.checkoutCalls) + } +} + +func TestBillingCheckoutResubscriptionReusesPendingAfterCanceledHistory(t *testing.T) { + h, store := setupCloudTestHandler(t) + defer store.Close() + buyer, err := store.CreateManagedCloudAccount(t.Context(), database.CreateManagedCloudAccountInput{Email: "resubscribe@example.test", HashedPassword: "hash", TeamName: "Resubscribe"}) + if err != nil { + t.Fatal(err) + } + if err := store.UpsertCloudBillingAccount(t.Context(), database.CloudBillingAccount{TenantID: buyer.TenantID, PlanCode: database.CloudPlanFree, PlanName: "Free", SubscriptionStatus: database.CloudSubscriptionStatusCanceled, StripeCustomerID: "cus_test", StripeSubscriptionID: "sub_newer"}); err != nil { + t.Fatal(err) + } + f := h.stripe.(*fakeStripeClient) + f.sessions = []*checkoutSessionOutput{ + {ID: "cs_newer_history", TenantID: buyer.TenantID.String(), Status: "complete", SubscriptionID: "sub_newer", SubscriptionStatus: "canceled"}, + {ID: "cs_older_history", TenantID: buyer.TenantID.String(), Status: "complete", SubscriptionID: "sub_older", SubscriptionStatus: "canceled"}, + } + for range 2 { + req := httptest.NewRequest("POST", "/", strings.NewReader(`{"plan_code":"pro","billing":"monthly"}`)) + req = req.WithContext(context.WithValue(req.Context(), shared.UserIDKey, buyer.UserID)) + w := httptest.NewRecorder() + h.handleCreateBillingCheckoutSession().ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("resubscribe=%d %s", w.Code, w.Body.String()) + } + } + if f.checkoutCalls != 1 { + t.Fatalf("resubscribe created %d sessions", f.checkoutCalls) + } +} + +func (f *fakeStripeClient) ListCheckoutSessions(_ context.Context, _ string) ([]*checkoutSessionOutput, error) { + if f.afterCheckoutList != nil { + f.afterCheckoutList() + } + return f.sessions, nil +} + +func TestCheckoutRetryPreservesConcurrentSubscriptionWebhook(t *testing.T) { + h, store := setupCloudTestHandler(t) + t.Cleanup(func() { _ = store.Close() }) + buyer, err := store.CreateManagedCloudAccount(t.Context(), database.CreateManagedCloudAccountInput{Email: "checkout-webhook-race@example.test", HashedPassword: "hash", TeamName: "Checkout race"}) + if err != nil { + t.Fatal(err) + } + if err := store.UpsertCloudBillingAccount(t.Context(), database.CloudBillingAccount{TenantID: buyer.TenantID, PlanCode: database.CloudPlanFree, PlanName: "Free", SubscriptionStatus: database.CloudSubscriptionStatusPendingCheckout, StripeCustomerID: "cus_test"}); err != nil { + t.Fatal(err) + } + f := h.stripe.(*fakeStripeClient) + f.sessions = []*checkoutSessionOutput{{ID: "cs_issued", URL: "https://checkout.stripe.test/issued", TenantID: buyer.TenantID.String(), Status: "open", PlanCode: "pro", BillingInterval: "monthly"}} + event := stripe.Event{ID: "evt_checkout_race", Type: "customer.subscription.updated", Data: &stripe.EventData{Raw: []byte(`{"id":"sub_paid","metadata":{"tenant_id":"` + buyer.TenantID.String() + `","plan_code":"pro","plan_name":"Pro"},"customer":{"id":"cus_test"},"status":"active","items":{"data":[{"price":{"id":"price_pro"}}]}}`)}} + f.afterCheckoutList = func() { + if err := h.handleStripeEvent(t.Context(), event); err != nil { + t.Fatal(err) + } + } + req := httptest.NewRequest("POST", "/", strings.NewReader(`{"plan_code":"pro","billing":"monthly"}`)) + req = req.WithContext(context.WithValue(req.Context(), shared.UserIDKey, buyer.UserID)) + w := httptest.NewRecorder() + h.handleCreateBillingCheckoutSession().ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("checkout retry: %d %s", w.Code, w.Body.String()) + } + account, err := store.GetCloudBillingAccount(t.Context(), buyer.TenantID) + if err != nil { + t.Fatal(err) + } + if account.PlanCode != database.CloudPlanPro || account.SubscriptionStatus != database.CloudSubscriptionStatusActive || account.StripeSubscriptionID != "sub_paid" { + t.Fatalf("checkout retry overwrote paid subscription: %+v", account) + } + if ent := h.ctx.Limits().TeamEntitlements(t.Context(), buyer.TenantID); ent.MaxRetentionDays != 365 { + t.Fatalf("paid entitlement lost: %+v", ent) + } +} +func (f *fakeStripeClient) GetCheckoutSession(_ context.Context, id string) (*checkoutSessionOutput, error) { + for _, s := range f.sessions { + if s.ID == id { + return s, nil + } + } + return nil, errCheckoutConflict +} +func (f *fakeStripeClient) ExpireCheckoutSession(ctx context.Context, id, _ string) (*checkoutSessionOutput, error) { + s, err := f.GetCheckoutSession(ctx, id) + if err == nil { + s.Status = "expired" + } + return s, err +} + +func TestCommerceSessionLifecycleAndAuthorization(t *testing.T) { + for _, protocol := range []string{"acp", "ucp"} { + t.Run(protocol, func(t *testing.T) { + h, store := setupCloudTestHandler(t) + defer store.Close() + h.ctx.Config.AIEnabled = true + h.profileCheck = func(context.Context, string) (int, error) { + return validateCommerceProfile([]byte(`{"ucp":{"version":"2026-08-25","capabilities":{"dev.ucp.shopping.checkout":[{"version":"2026-08-25"}]}}}`)) + } + buyer, err := store.CreateManagedCloudAccount(t.Context(), database.CreateManagedCloudAccountInput{Email: protocol + "@example.test", HashedPassword: "hashed", TeamName: "Checkout"}) + if err != nil { + t.Fatal(err) + } + fake := h.stripe.(*fakeStripeClient) + fake.prices = map[string]*stripePriceOutput{"price_pro": {AmountMinor: 1200, Currency: "EUR", Interval: "month", Active: true}} + token, err := auth.GenerateToken(h.ctx.Config.JWTSecret, h.ctx.Config.PublicURL, buyer.UserID) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + h.registerCommerce(mux) + base := "/api/cloud/commerce/" + protocol + "/checkout_sessions" + body := `{"line_items":[{"id":"pro:monthly"}],"currency":"eur","capabilities":{}}` + if protocol == "ucp" { + base = "/api/cloud/commerce/ucp/checkout-sessions" + body = `{"ucp":{"version":"2026-08-25"},"line_items":[{"item":{"id":"pro:monthly"},"quantity":1}]}` + } + call := func(method, path, key, payload string, cookie bool, bearer string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(payload)) + req.Header.Set("API-Version", acpVersion) + req.Header.Set("Idempotency-Key", key) + req.Header.Set("Request-Id", "request-test") + req.Header.Set("UCP-Agent", `profile="https://agent.example/.well-known/ucp"`) + if cookie { + req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token}) + } + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + return w + } + for _, credential := range []struct { + cookie bool + bearer string + }{{false, ""}, {false, "invalid"}, {true, "invalid"}} { + if w := call("POST", base, "denied", body, credential.cookie, credential.bearer); w.Code != 401 { + t.Fatalf("auth=%d %s", w.Code, w.Body.String()) + } + } + if fake.checkoutCalls != 0 { + t.Fatal("unauthorized provider call") + } + if protocol == "acp" { + for _, invalid := range []struct{ key, body string }{{"quantity", `{"line_items":[{"id":"pro:monthly","quantity":2}]}`}, {"multiple", `{"line_items":[{"id":"pro:monthly"},{"id":"business:monthly"}]}`}} { + if w := call("POST", base, invalid.key, invalid.body, true, ""); w.Code != http.StatusBadRequest { + t.Fatalf("unsupported plan quantity: %d %s", w.Code, w.Body.String()) + } + } + } + w := call("POST", base, "first", body, true, "") + if w.Code != 201 || !strings.Contains(w.Body.String(), `"requires_escalation"`) || !strings.Contains(w.Body.String(), `"continue_url"`) { + t.Fatalf("create=%d %s", w.Code, w.Body.String()) + } + if w.Header().Get("Cache-Control") != "private, no-store" { + t.Fatal("cache policy missing") + } + w = call("POST", base, "first", body, true, "") + if w.Code >= 300 || fake.checkoutCalls != 1 { + t.Fatalf("retry=%d creates=%d %s", w.Code, fake.checkoutCalls, w.Body.String()) + } + mismatch := strings.ReplaceAll(body, "pro:monthly", "business:monthly") + w = call("POST", base, "first", mismatch, true, "") + want := 409 + if protocol == "acp" { + want = 422 + } + if w.Code != want { + t.Fatalf("mismatch=%d want=%d %s", w.Code, want, w.Body.String()) + } + w = call("POST", base, "other", body, true, "") + if w.Code != 201 || fake.checkoutCalls != 1 { + t.Fatalf("shared pending=%d %s", w.Code, w.Body.String()) + } + // A member of the same team cannot see or mutate its billing session. + member, err := store.CreateUser(t.Context(), protocol+"-member@example.test", "hash") + if err != nil { + t.Fatal(err) + } + if err := store.AddTeamMember(t.Context(), buyer.TenantID, member, database.TenantRoleMember, buyer.UserID); err != nil { + t.Fatal(err) + } + if err := store.SetActiveTenantID(t.Context(), member, buyer.TenantID); err != nil { + t.Fatal(err) + } + ownerToken := token + token, err = auth.GenerateToken(h.ctx.Config.JWTSecret, h.ctx.Config.PublicURL, member) + if err != nil { + t.Fatal(err) + } + if w := call("GET", base+"/cs_test", "", "", true, ""); w.Code != 403 { + t.Fatalf("member status=%d %s", w.Code, w.Body.String()) + } + token = ownerToken + fake.sessions[0].TenantID = uuid.New().String() + if w := call("GET", base+"/cs_test", "", "", true, ""); w.Code != 404 { + t.Fatalf("cross-team session=%d %s", w.Code, w.Body.String()) + } + fake.sessions[0].TenantID = buyer.TenantID.String() + w = call("POST", base+"/cs_test/complete", "complete", "{}", true, "") + if w.Code != 200 || !strings.Contains(w.Body.String(), `"requires_escalation"`) { + t.Fatalf("complete=%d %s", w.Code, w.Body.String()) + } + fake.sessions[0].Status = "complete" + fake.sessions[0].PaymentStatus = "unpaid" + w = call("GET", base+"/cs_test", "", "", true, "") + if !strings.Contains(w.Body.String(), `"complete_in_progress"`) { + t.Fatal(w.Body.String()) + } + fake.sessions[0].PaymentStatus = "paid" + fake.sessions[0].SubscriptionID = "sub_real" + w = call("GET", base+"/cs_test", "", "", true, "") + if !strings.Contains(w.Body.String(), `"completed"`) || !strings.Contains(w.Body.String(), `"sub_real"`) { + t.Fatal(w.Body.String()) + } + if w := call("POST", base, "new-after-payment", body, true, ""); w.Code != 409 { + t.Fatalf("duplicate subscription=%d %s", w.Code, w.Body.String()) + } + if w := call("POST", base+"/cs_test/cancel", "cancel", "{}", true, ""); w.Code != 405 { + t.Fatalf("paid cancellation=%d", w.Code) + } + }) + } +} + +type commerceFollower struct{} + +func (commerceFollower) IsLeader() bool { return false } +func (commerceFollower) GetLeaderAddr() string { return "" } + +func TestCommerceUnavailableRailsNeverIssuePaymentProof(t *testing.T) { + h, store := setupCloudTestHandler(t) + defer store.Close() + h.ctx.Config.AIEnabled = true + buyer, err := store.CreateManagedCloudAccount(t.Context(), database.CreateManagedCloudAccountInput{Email: "rails@example.test", HashedPassword: "hash", TeamName: "Rails"}) + if err != nil { + t.Fatal(err) + } + token, err := auth.GenerateToken(h.ctx.Config.JWTSecret, h.ctx.Config.PublicURL, buyer.UserID) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + h.registerCommerce(mux) + for _, rail := range []string{"mpp", "x402"} { + req := httptest.NewRequest(http.MethodPost, "/api/cloud/commerce/"+rail+"/purchases/cs_fake/pay", strings.NewReader(`{}`)) + req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token}) + req.Header.Set("Payment-Signature", "untrusted") + req.Header.Set("Payment-Authorization", "untrusted") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != 501 || !strings.Contains(w.Body.String(), "payment_rail_unavailable") { + t.Fatalf("rail=%s status=%d %s", rail, w.Code, w.Body.String()) + } + for _, header := range []string{"WWW-Authenticate", "Payment-Receipt", "Payment-Required", "Payment-Response"} { + if w.Header().Get(header) != "" { + t.Fatalf("fabricated %s", header) + } + } + } + if f := h.stripe.(*fakeStripeClient); f.checkoutCalls != 0 || f.lastCustomerInput != nil { + t.Fatal("unsupported rail mutated Stripe") + } + h.ctx.Cluster = commerceFollower{} + w := httptest.NewRecorder() + h.commerceGate(func(http.ResponseWriter, *http.Request) { t.Fatal("follower handler reached") })(w, httptest.NewRequest("POST", "/", nil)) + if w.Code != 503 { + t.Fatalf("follower=%d", w.Code) + } +} + +func TestReuseCheckoutRejectsCompetingTermsAndPendingPayment(t *testing.T) { + h, f := planCatalogTestHandler() + teamID := uuid.New() + f.sessions = []*checkoutSessionOutput{{ID: "cs_pending", TenantID: teamID.String(), Status: "open", PlanCode: "pro", BillingInterval: "monthly", PriceID: "pro_month"}} + input := createCheckoutSessionInput{CustomerID: "cus_test", TenantID: teamID, PlanCode: "business", BillingInterval: "monthly", PriceID: "business_month"} + if _, err := h.reuseOrCreateCheckout(t.Context(), input); !errors.Is(err, errCheckoutConflict) { + t.Fatalf("competing plan=%v", err) + } + input.PlanCode = "pro" + input.PriceID = "changed_price" + if _, err := h.reuseOrCreateCheckout(t.Context(), input); !errors.Is(err, errCheckoutConflict) { + t.Fatalf("changed price=%v", err) + } + f.sessions[0].Status = "complete" + f.sessions[0].SubscriptionID = "sub_pending" + input.PriceID = "pro_month" + if _, err := h.reuseOrCreateCheckout(t.Context(), input); !errors.Is(err, errCheckoutConflict) { + t.Fatalf("pending completion=%v", err) + } + if f.checkoutCalls != 0 { + t.Fatal("created competing subscription checkout") + } +} + +func TestCommerceCancelExpiresOnlyThePendingCheckout(t *testing.T) { + h, store := setupCloudTestHandler(t) + defer store.Close() + h.ctx.Config.AIEnabled = true + buyer, err := store.CreateManagedCloudAccount(t.Context(), database.CreateManagedCloudAccountInput{Email: "cancel@example.test", HashedPassword: "hash", TeamName: "Cancel"}) + if err != nil { + t.Fatal(err) + } + f := h.stripe.(*fakeStripeClient) + f.prices = map[string]*stripePriceOutput{"price_pro": {AmountMinor: 1200, Currency: "EUR", Interval: "month", Active: true}} + call := func(action, id, key, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest("POST", "/", strings.NewReader(body)) + req.SetPathValue("id", id) + req.Header.Set("API-Version", acpVersion) + req.Header.Set("Idempotency-Key", key) + req = req.WithContext(context.WithValue(req.Context(), shared.UserIDKey, buyer.UserID)) + w := httptest.NewRecorder() + h.handleCommerceSession("acp", action).ServeHTTP(w, req) + return w + } + body := `{"line_items":[{"id":"pro:monthly"}],"currency":"eur","capabilities":{}}` + if w := call("create", "", "first", body); w.Code != 201 { + t.Fatalf("create=%d %s", w.Code, w.Body.String()) + } + firstKey := f.lastCheckoutInput.IdempotencyKey + if firstKey == "" { + t.Fatal("Stripe idempotency key missing") + } + if w := call("cancel", "cs_test", "cancel", "{}"); w.Code != 200 || !strings.Contains(w.Body.String(), `"canceled"`) { + t.Fatalf("cancel=%d %s", w.Code, w.Body.String()) + } + if w := call("cancel", "cs_test", "cancel", "{}"); w.Code != 200 || w.Header().Get("Idempotent-Replayed") != "true" { + t.Fatalf("retry cancel=%d %s", w.Code, w.Body.String()) + } + if w := call("create", "", "second", body); w.Code != 201 { + t.Fatalf("new create=%d %s", w.Code, w.Body.String()) + } + if f.checkoutCalls != 2 || f.lastCheckoutInput.IdempotencyKey == firstKey { + t.Fatal("fresh Checkout did not follow expired prior session") + } + account, err := store.GetCloudBillingAccount(t.Context(), buyer.TenantID) + if err != nil { + t.Fatal(err) + } + if account.PlanCode != database.CloudPlanFree || account.StripeSubscriptionID != "" { + t.Fatalf("cancel changed subscription authority: %+v", account) + } +} + +func TestBillingFinalTeamContextMatchesApproval(t *testing.T) { + teamID := uuid.New() + for _, tc := range []struct { + marker, expected string + want bool + }{{"", "", true}, {"webmcp", teamID.String(), true}, {"webmcp", uuid.New().String(), false}, {"webmcp", "", false}, {"webmcp", "invalid", false}} { + req := httptest.NewRequest("POST", "/", nil) + if tc.marker != "" { + req.Header.Set("X-HitKeep-Agent", tc.marker) + } + if tc.expected != "" { + req.Header.Set("X-HitKeep-Agent-Team", tc.expected) + } + w := httptest.NewRecorder() + if got := shared.CheckAgentTeam(w, req, teamID); got != tc.want { + t.Fatalf("context match=%v want %v", got, tc.want) + } + } +} diff --git a/server/cloud/commerce_external_test.go b/server/cloud/commerce_external_test.go new file mode 100644 index 00000000..0b0afd99 --- /dev/null +++ b/server/cloud/commerce_external_test.go @@ -0,0 +1,160 @@ +//go:build billing + +package cloud + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + "uuid" + + legacyuuid "github.com/google/uuid" + + "hitkeep/database" + "hitkeep/oauth" +) + +func TestCommerceExternalOAuthLifecycle(t *testing.T) { + for _, protocol := range []string{"acp", "ucp"} { + t.Run(protocol, func(t *testing.T) { + h, store := setupCloudTestHandler(t) + t.Cleanup(func() { _ = store.Close() }) + h.ctx.Config.AIEnabled = true + h.profileCheck = func(context.Context, string) (int, error) { return 0, nil } + buyer, err := store.CreateManagedCloudAccount(t.Context(), database.CreateManagedCloudAccountInput{Email: protocol + "-external@example.test", HashedPassword: "hash", TeamName: "External"}) + if err != nil { + t.Fatal(err) + } + provider, err := oauth.New(store, oauth.Options{Issuer: "https://example.test", MCPPath: "/mcp", BrowserSigningKey: []byte(strings.Repeat("k", 32)), IsLeader: func() bool { return true }, AgentActionsEnabled: func() bool { return h.ctx.Config.AIEnabled }}) + if err != nil { + t.Fatal(err) + } + h.ctx.OAuthProvider = provider + fake := h.stripe.(*fakeStripeClient) + fake.prices = map[string]*stripePriceOutput{"price_pro": {AmountMinor: 1200, Currency: "EUR", Interval: "month", Active: true}} + issue := func(team legacyuuid.UUID, scopes []string, sites []uuid.UUID) (string, uuid.UUID) { + t.Helper() + id := uuid.NewV7() + entropy := sha256.Sum256([]byte(id.String())) + token := "hk_oauth_at_" + base64.RawURLEncoding.EncodeToString(entropy[:]) + tx, err := store.BeginOAuth(t.Context()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback() }() + now := time.Now().UTC() + if err := tx.CreateGrant(t.Context(), database.OAuthGrant{ID: id, UserID: uuid.UUID(buyer.UserID), TeamID: uuid.UUID(team), ClientID: "external-test", ClientName: "External test", Audience: provider.Resource("api"), Scopes: scopes, SiteIDs: sites, CreatedAt: now, ExpiresAt: now.Add(time.Hour)}); err != nil { + t.Fatal(err) + } + if err := tx.CreateCredential(t.Context(), database.OAuthCredential{Kind: "access", Signature: fmt.Sprintf("%x", sha256.Sum256([]byte(token))), GrantID: id, TeamID: uuid.UUID(team), Payload: []byte(`{}`), ExpiresAt: now.Add(time.Hour)}); err != nil { + t.Fatal(err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + return token, id + } + // Billing consent is team-scoped and must work before the first site exists. + token, grantID := issue(buyer.TenantID, []string{oauth.ScopeBillingWrite}, nil) + mux := http.NewServeMux() + h.registerCommerce(mux) + base := "/api/cloud/commerce/acp/checkout_sessions" + body := `{"line_items":[{"id":"pro:monthly"}],"currency":"eur","capabilities":{}}` + if protocol == "ucp" { + base = "/api/cloud/commerce/ucp/checkout-sessions" + body = `{"line_items":[{"item":{"id":"pro:monthly"},"quantity":1}]}` + } + call := func(method, path, key, payload, credential string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(method, path, strings.NewReader(payload)) + r.Header.Set("API-Version", acpVersion) + r.Header.Set("Request-Id", "external-request") + r.Header.Set("UCP-Agent", `profile="https://agent.example/.well-known/ucp"`) + r.Header.Set("Idempotency-Key", key) + if credential != "" { + r.Header.Set("Authorization", "Bearer "+credential) + } + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + return w + } + if w := call("POST", base, "unauth", body, ""); w.Code != 401 { + t.Fatalf("unauthenticated=%d %s", w.Code, w.Body.String()) + } + w := call("POST", base, "create", body, token) + if w.Code != 201 || !strings.Contains(w.Body.String(), `"requires_escalation"`) || !strings.Contains(w.Body.String(), `"continue_url":"https://checkout.stripe.test/session"`) { + t.Fatalf("external create=%d %s", w.Code, w.Body.String()) + } + if w := call("POST", base, "create", body, token); w.Code != 201 || fake.checkoutCalls != 1 || w.Header().Get("Idempotent-Replayed") != "true" { + t.Fatalf("replay=%d creates=%d %s", w.Code, fake.checkoutCalls, w.Body.String()) + } + if w := call("POST", base+"/cs_test/complete", "complete", `{}`, token); w.Code != 200 || !strings.Contains(w.Body.String(), `"requires_escalation"`) { + t.Fatalf("machine completion=%d %s", w.Code, w.Body.String()) + } + other, err := store.CreateTenant(t.Context(), buyer.UserID, "Other", "") + if err != nil { + t.Fatal(err) + } + if err := store.SetActiveTenantID(t.Context(), buyer.UserID, other.ID); err != nil { + t.Fatal(err) + } + if w := call("GET", base+"/cs_test", "", "", token); w.Code != 200 { + t.Fatalf("grant team changed with dashboard=%d %s", w.Code, w.Body.String()) + } + wrongTeam, _ := issue(other.ID, []string{oauth.ScopeBillingWrite}, nil) + if w := call("GET", base+"/cs_test", "", "", wrongTeam); w.Code != 404 { + t.Fatalf("cross-team=%d %s", w.Code, w.Body.String()) + } + if err := store.SetActiveTenantID(t.Context(), buyer.UserID, buyer.TenantID); err != nil { + t.Fatal(err) + } + site, err := store.CreateSite(t.Context(), buyer.UserID, protocol+"-external.example.test") + if err != nil { + t.Fatal(err) + } + readOnly, _ := issue(buyer.TenantID, []string{oauth.ScopeAnalyticsRead}, []uuid.UUID{uuid.UUID(site.ID)}) + if w := call("POST", base, "read-only", body, readOnly); w.Code != 403 { + t.Fatalf("read-only=%d %s", w.Code, w.Body.String()) + } + h.ctx.Config.AIEnabled = false + if w := call("GET", base+"/cs_test", "", "", token); w.Code != 404 { + t.Fatalf("AI off=%d %s", w.Code, w.Body.String()) + } + h.ctx.Config.AIEnabled = true + update := "POST" + if protocol == "ucp" { + update = "PUT" + } + if w := call(update, base+"/cs_test", "update", body, token); w.Code != 200 || !strings.Contains(w.Body.String(), `"requires_escalation"`) { + t.Fatalf("external update=%d %s", w.Code, w.Body.String()) + } + if w := call("POST", base+"/cs_test/cancel", "cancel", `{}`, token); w.Code != 200 || !strings.Contains(w.Body.String(), `"canceled"`) { + t.Fatalf("external cancel=%d %s", w.Code, w.Body.String()) + } + if err := store.AddTeamMember(t.Context(), buyer.TenantID, buyer.UserID, database.TenantRoleMember, buyer.UserID); err != nil { + t.Fatal(err) + } + if w := call("POST", base, "create", body, token); w.Code != 403 { + t.Fatalf("lost billing role replay=%d %s", w.Code, w.Body.String()) + } + if err := store.AddTeamMember(t.Context(), buyer.TenantID, buyer.UserID, database.TenantRoleOwner, buyer.UserID); err != nil { + t.Fatal(err) + } + if err := store.RevokeOAuthGrant(t.Context(), uuid.UUID(buyer.UserID), grantID); err != nil { + t.Fatal(err) + } + if w := call("POST", base, "create", body, token); w.Code != 401 { + t.Fatalf("revoked replay=%d %s", w.Code, w.Body.String()) + } + if fake.checkoutCalls != 1 { + t.Fatalf("unauthorized calls created %d sessions", fake.checkoutCalls) + } + }) + } +} diff --git a/server/cloud/commerce_profile.go b/server/cloud/commerce_profile.go new file mode 100644 index 00000000..cae4c817 --- /dev/null +++ b/server/cloud/commerce_profile.go @@ -0,0 +1,99 @@ +//go:build billing + +package cloud + +import ( + "context" + "crypto/tls" + "errors" + "io" + "mime" + "net" + "net/http" + "net/url" + "strings" + "time" + + json "hitkeep/jsonapi" + "hitkeep/webhooks" +) + +// Fetch only the public profile; it is capability metadata, never billing authority. +func fetchCommerceProfile(ctx context.Context, header string) (int, error) { + raw, ok := strings.CutPrefix(strings.TrimSpace(header), "profile=") + if !ok || len(raw) > 2048 { + return http.StatusBadRequest, errors.New("UCP-Agent profile is required") + } + if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' { + return http.StatusBadRequest, errors.New("Invalid UCP-Agent profile") + } + profile := raw[1 : len(raw)-1] + for _, character := range profile { + if character < 0x21 || character > 0x7e || character == '"' || character == '\\' { + return http.StatusBadRequest, errors.New("Invalid UCP-Agent profile") + } + } + target, err := url.Parse(profile) + if err != nil || target.Scheme != "https" || target.Hostname() == "" || target.User != nil || target.Path != "/.well-known/ucp" || target.RawQuery != "" || target.ForceQuery || strings.Contains(profile, "#") || (target.Port() != "" && target.Port() != "443") { + return http.StatusBadRequest, errors.New("UCP profile must be public HTTPS at /.well-known/ucp") + } + transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13}, DisableKeepAlives: true, MaxResponseHeaderBytes: 16 << 10, TLSHandshakeTimeout: 3 * time.Second, ResponseHeaderTimeout: 3 * time.Second, + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host) + if err != nil || len(ips) == 0 { + return nil, errors.New("Profile DNS unavailable") + } + for _, ip := range ips { + if ip.Zone() != "" || !webhooks.AddressAllowed(ip) { + return nil, errors.New("Profile destination is not public") + } + } + return (&net.Dialer{Timeout: 3 * time.Second}).DialContext(ctx, network, net.JoinHostPort(ips[0].Unmap().String(), port)) + }, + } + defer transport.CloseIdleConnections() + client := &http.Client{Timeout: 5 * time.Second, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) + if err != nil { + return http.StatusBadRequest, errors.New("Invalid profile request") + } + req.Header.Set("Accept", "application/json") + response, err := client.Do(req) // #nosec G704 -- public IPs are checked and pinned in DialContext; HTTPS, no proxies or redirects. + if err != nil { + return http.StatusFailedDependency, errors.New("UCP profile unavailable") + } + defer response.Body.Close() + media, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + if err != nil || media != "application/json" || response.StatusCode != http.StatusOK || response.Header.Get("Content-Encoding") != "" { + return http.StatusUnprocessableEntity, errors.New("UCP profile must return JSON without redirects") + } + body, err := io.ReadAll(io.LimitReader(response.Body, 64<<10+1)) + if err != nil || len(body) > 64<<10 { + return http.StatusUnprocessableEntity, errors.New("UCP profile exceeds bounded response") + } + return validateCommerceProfile(body) +} + +func validateCommerceProfile(body []byte) (int, error) { + var profile struct { + UCP struct { + Version string `json:"version"` + Capabilities map[string][]struct { + Version string `json:"version"` + } `json:"capabilities"` + } `json:"ucp"` + } + if err := json.Unmarshal(body, &profile); err != nil || profile.UCP.Version != ucpVersion { + return http.StatusUnprocessableEntity, errors.New("Unsupported UCP profile version") + } + for _, capability := range profile.UCP.Capabilities["dev.ucp.shopping.checkout"] { + if capability.Version == ucpVersion { + return 0, nil + } + } + return http.StatusUnprocessableEntity, errors.New("UCP checkout capability version is not supported") +} diff --git a/server/cloud/commerce_profile_test.go b/server/cloud/commerce_profile_test.go new file mode 100644 index 00000000..8244af31 --- /dev/null +++ b/server/cloud/commerce_profile_test.go @@ -0,0 +1,21 @@ +//go:build billing + +package cloud + +import ( + "net/http" + "testing" +) + +func TestCommerceProfileRejectsUnusableNegotiation(t *testing.T) { + for _, raw := range []string{`{}`, `{"ucp":{"version":"2026-01-11"}}`, `{"ucp":{"version":"2026-08-25","capabilities":{"dev.ucp.shopping.checkout":[{"version":"2026-01-11"}]}}}`} { + if status, err := validateCommerceProfile([]byte(raw)); err == nil || status != http.StatusUnprocessableEntity { + t.Fatalf("accepted %s", raw) + } + } + for _, header := range []string{"", `profile="http://example.com/.well-known/ucp"`, `profile="https://user:pass@example.com/.well-known/ucp"`, `profile="https://example.com:8443/.well-known/ucp"`, `profile="https://example.com/.well-known/ucp?private=1"`, `profile="https://example.com/profile"`, `profile="https://127.0.0.1/.well-known/ucp"`, `profile="https://169.254.169.254/.well-known/ucp"`} { + if _, err := fetchCommerceProfile(t.Context(), header); err == nil { + t.Fatalf("accepted unsafe profile %s", header) + } + } +} diff --git a/server/cloud/handlers_billing.go b/server/cloud/handlers_billing.go index 7483659b..f4f6e478 100644 --- a/server/cloud/handlers_billing.go +++ b/server/cloud/handlers_billing.go @@ -5,6 +5,8 @@ package cloud import ( "context" "errors" + "fmt" + "io" "net/http" "net/url" "strconv" @@ -39,6 +41,9 @@ const ( type stripeClient interface { CreateCustomer(context.Context, createCustomerInput) (string, error) CreateCheckoutSession(context.Context, createCheckoutSessionInput) (*checkoutSessionOutput, error) + GetCheckoutSession(context.Context, string) (*checkoutSessionOutput, error) + ListCheckoutSessions(context.Context, string) ([]*checkoutSessionOutput, error) + ExpireCheckoutSession(context.Context, string, string) (*checkoutSessionOutput, error) CreatePortalSession(context.Context, createPortalSessionInput) (*portalSessionOutput, error) GetCharge(context.Context, string) (*stripeChargeOutput, error) GetPrice(context.Context, string) (*stripePriceOutput, error) @@ -49,9 +54,12 @@ type stripeWebhookVerifier interface { } type handler struct { - ctx *shared.Context - stripe stripeClient - webhooks stripeWebhookVerifier + ctx *shared.Context + stripe stripeClient + webhooks stripeWebhookVerifier + checkoutMu sync.Mutex + commerceMu sync.Mutex + profileCheck func(context.Context, string) (int, error) } type createCustomerInput struct { @@ -66,6 +74,7 @@ type createCustomerInput struct { } type createCheckoutSessionInput struct { + IdempotencyKey string CustomerID string PriceID string SuccessURL string @@ -80,8 +89,21 @@ type createCheckoutSessionInput struct { } type checkoutSessionOutput struct { - ID string - URL string + PriceID string + ID string + URL string + Status string + PaymentStatus string + CustomerID string + SubscriptionID string + SubscriptionStatus string + TenantID string + PlanCode string + BillingInterval string + Currency string + AmountSubtotal int64 + AmountTotal int64 + ExpiresAt int64 } type createPortalSessionInput struct { @@ -121,6 +143,8 @@ func Register(mux *http.ServeMux, ctx *shared.Context) { webhooks: stripeWebhookSDK{}, } + h.registerCommerce(mux) + mux.HandleFunc("POST /api/cloud/signup", ctx.Handler(shared.HandlerConfig{ RateLimiter: ctx.AuthLimiter, }, h.handleSignup())) @@ -193,6 +217,7 @@ type billingPortalSessionRequest struct { } type billingCheckoutSessionRequest struct { + teamID uuid.UUID PlanCode string `json:"plan_code"` BillingInterval string `json:"billing"` Locale string `json:"locale"` @@ -506,6 +531,26 @@ func (h *handler) handleListCloudPlans() http.HandlerFunc { } mu.Unlock() + w.Header().Add("Vary", "Accept") + if h.ctx.Config.AIEnabled && strings.Contains(r.Header.Get("Accept"), "text/markdown") { + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + var body strings.Builder + body.WriteString("# HitKeep Cloud plans\n\nPrices are recurring in the displayed currency. A billing administrator reviews and approves the subscription and payment in Checkout.\n\n") + for _, plan := range response { + fmt.Fprintf(&body, "## %s\n\n", plan.Name) + if plan.Code == database.CloudPlanFree { + body.WriteString("Free plan.\n\n") + } + for _, interval := range []string{database.CloudBillingIntervalMonthly, database.CloudBillingIntervalAnnual} { + if price, ok := plan.Prices[interval]; ok { + fmt.Fprintf(&body, "- %s: %d %s minor units; item `%s:%s`; [review purchase](%s).\n", interval, price.AmountMinor, price.Currency, plan.Code, interval, price.PurchaseReviewURL) + } + } + body.WriteString("\n") + } + _, _ = io.WriteString(w, body.String()) + return + } shared.WriteJSON(r.Context(), w, http.StatusOK, response) } } diff --git a/server/cloud/handlers_billing_sessions.go b/server/cloud/handlers_billing_sessions.go index f73e34ad..2d9daf4b 100644 --- a/server/cloud/handlers_billing_sessions.go +++ b/server/cloud/handlers_billing_sessions.go @@ -13,6 +13,7 @@ import ( "github.com/google/uuid" "hitkeep/appurl" + authcore "hitkeep/auth" "hitkeep/config" "hitkeep/database" json "hitkeep/jsonapi" @@ -91,6 +92,10 @@ func (h *handler) handleCreateBillingPortalSession() http.HandlerFunc { return } + if !shared.CheckAgentTeam(w, r, activeTenantID) { + return + } + account, err := h.ctx.Store.GetCloudBillingAccount(r.Context(), activeTenantID) if errors.Is(err, database.ErrCloudBillingAccountNotFound) || account == nil { newAccount := database.CloudBillingAccount{ @@ -146,157 +151,184 @@ func (h *handler) handleCreateBillingPortalSession() http.HandlerFunc { func (h *handler) handleCreateBillingCheckoutSession() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if !h.ctx.Config.CloudHosted { - http.Error(w, "Not found", http.StatusNotFound) + var req billingCheckoutSessionRequest + if err := json.UnmarshalRead(http.MaxBytesReader(w, r.Body, 64<<10), &req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) return } - if h.ctx.Store == nil { - http.Error(w, "Service not available on this node", http.StatusServiceUnavailable) - return + if session := h.createBillingCheckout(w, r, req); session != nil { + shared.WriteJSON(r.Context(), w, http.StatusOK, billingCheckoutSessionResponse{URL: session.URL}) } + } +} - userID := shared.GetUserIDFromContext(r) - if userID == uuid.Nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } +func (h *handler) createBillingCheckout(w http.ResponseWriter, r *http.Request, req billingCheckoutSessionRequest) *checkoutSessionOutput { + h.checkoutMu.Lock() + defer h.checkoutMu.Unlock() + if h.ctx.Cluster != nil && !h.ctx.Cluster.IsLeader() { + http.Error(w, "Service not available on this node", http.StatusServiceUnavailable) + return nil + } - activeTenantID, err := h.ctx.Store.GetActiveTenantID(r.Context(), userID) + if !h.ctx.Config.CloudHosted { + http.Error(w, "Not found", http.StatusNotFound) + return nil + } + if h.ctx.Store == nil { + http.Error(w, "Service not available on this node", http.StatusServiceUnavailable) + return nil + } + + userID := shared.GetUserIDFromContext(r) + if userID == uuid.Nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return nil + } + + activeTenantID := req.teamID + if activeTenantID == uuid.Nil { + var err error + activeTenantID, err = h.ctx.Store.GetActiveTenantID(r.Context(), userID) if err != nil { http.Error(w, "Unable to resolve active team", http.StatusBadRequest) - return + return nil } + } + if !shared.CheckAgentTeam(w, r, activeTenantID) { + return nil + } + role, err := h.ctx.Store.GetTenantRole(r.Context(), activeTenantID, userID) + if err != nil || !authcore.TeamRoleHasCapability(role, authcore.CapTeamManageSettings) { + http.Error(w, "Access denied", http.StatusForbidden) + return nil + } - account, err := h.ctx.Store.GetCloudBillingAccount(r.Context(), activeTenantID) - if errors.Is(err, database.ErrCloudBillingAccountNotFound) || account == nil { - // Auto-create a free billing account for legacy users who signed up - // before the billing account table existed. - newAccount := database.CloudBillingAccount{ - TenantID: activeTenantID, - PlanCode: database.CloudPlanFree, - PlanName: planNameForCode(database.CloudPlanFree), - SubscriptionStatus: database.CloudSubscriptionStatusFree, - } - if upsertErr := h.ctx.Store.UpsertCloudBillingAccount(r.Context(), newAccount); upsertErr != nil { - shared.LoggerFromContext(r.Context()).Error("Failed to auto-create cloud billing account", "error", upsertErr, "team_id", activeTenantID) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - account = &newAccount - } - if err != nil && !errors.Is(err, database.ErrCloudBillingAccountNotFound) { - shared.LoggerFromContext(r.Context()).Error("Failed to load cloud billing account", "error", err) + account, err := h.ctx.Store.GetCloudBillingAccount(r.Context(), activeTenantID) + if errors.Is(err, database.ErrCloudBillingAccountNotFound) || account == nil { + // Auto-create a free billing account for legacy users who signed up + // before the billing account table existed. + newAccount := database.CloudBillingAccount{ + TenantID: activeTenantID, + PlanCode: database.CloudPlanFree, + PlanName: planNameForCode(database.CloudPlanFree), + SubscriptionStatus: database.CloudSubscriptionStatusFree, + } + if upsertErr := h.ctx.Store.UpsertCloudBillingAccount(r.Context(), newAccount); upsertErr != nil { + shared.LoggerFromContext(r.Context()).Error("Failed to auto-create cloud billing account", "error", upsertErr, "team_id", activeTenantID) http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - if strings.TrimSpace(h.ctx.Config.StripeSecretKey) == "" { - http.Error(w, "Stripe secret key is not configured", http.StatusNotImplemented) - return - } - - var req billingCheckoutSessionRequest - if err := json.UnmarshalRead(r.Body, &req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - req.PlanCode = normalizePlanCode(req.PlanCode) - req.BillingInterval = normalizeBillingInterval(req.BillingInterval) - req.Locale = normalizeStripeLocale(req.Locale) - if req.PlanCode != database.CloudPlanPro && req.PlanCode != database.CloudPlanBusiness { - http.Error(w, "Paid plan code is required", http.StatusBadRequest) - return + return nil } + account = &newAccount + } + if err != nil && !errors.Is(err, database.ErrCloudBillingAccountNotFound) { + shared.LoggerFromContext(r.Context()).Error("Failed to load cloud billing account", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return nil + } + if strings.TrimSpace(h.ctx.Config.StripeSecretKey) == "" { + http.Error(w, "Stripe secret key is not configured", http.StatusNotImplemented) + return nil + } - currentPlanCode, _ := effectivePlanCode(account) - if currentPlanCode != database.CloudPlanFree { - http.Error(w, "Use billing portal to manage an existing paid plan", http.StatusConflict) - return - } + req.PlanCode = normalizePlanCode(req.PlanCode) + req.BillingInterval = normalizeBillingInterval(req.BillingInterval) + req.Locale = normalizeStripeLocale(req.Locale) + if req.PlanCode != database.CloudPlanPro && req.PlanCode != database.CloudPlanBusiness { + http.Error(w, "Paid plan code is required", http.StatusBadRequest) + return nil + } - user, err := h.ctx.Store.GetUserByID(r.Context(), userID) - if err != nil || user == nil { - shared.LoggerFromContext(r.Context()).Error("Failed to load user for billing checkout", "error", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } + currentPlanCode, _ := effectivePlanCode(account) + if currentPlanCode != database.CloudPlanFree { + http.Error(w, "Use billing portal to manage an existing paid plan", http.StatusConflict) + return nil + } - team, err := h.ctx.Store.GetTenant(r.Context(), activeTenantID) - if err != nil || team == nil { - shared.LoggerFromContext(r.Context()).Error("Failed to load team for billing checkout", "error", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } + user, err := h.ctx.Store.GetUserByID(r.Context(), userID) + if err != nil || user == nil { + shared.LoggerFromContext(r.Context()).Error("Failed to load user for billing checkout", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return nil + } - customerID := strings.TrimSpace(account.StripeCustomerID) - if customerID == "" { - customerID, err = h.stripe.CreateCustomer(r.Context(), createCustomerInput{ - Email: strings.TrimSpace(strings.ToLower(user.Email)), - Name: stripeCustomerName(user, team.Name), - UserID: user.ID, - TenantID: activeTenantID, - PlanCode: req.PlanCode, - Jurisdiction: strings.TrimSpace(strings.ToUpper(h.ctx.Config.CloudJurisdiction)), - IdempotencyKey: stripeCustomerCreateIdempotencyKey(user.ID, activeTenantID, user.Email), - }) - if err != nil { - shared.LoggerFromContext(r.Context()).Error("Failed to create Stripe customer for billing checkout", slog.Group("stripe", stripeErrorLogAttrs(err)...)) - http.Error(w, "Unable to start checkout", http.StatusBadGateway) - return - } - } + team, err := h.ctx.Store.GetTenant(r.Context(), activeTenantID) + if err != nil || team == nil { + shared.LoggerFromContext(r.Context()).Error("Failed to load team for billing checkout", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return nil + } - priceID := priceIDForPlan(h.ctx.Config, req.PlanCode, req.BillingInterval) - if priceID == "" { - http.Error(w, "Plan is not configured for checkout", http.StatusBadRequest) - return - } + priceID := priceIDForPlan(h.ctx.Config, req.PlanCode, req.BillingInterval) + if priceID == "" { + http.Error(w, "Plan is not configured for checkout", http.StatusBadRequest) + return nil + } - session, err := h.stripe.CreateCheckoutSession(r.Context(), createCheckoutSessionInput{ - CustomerID: customerID, - PriceID: priceID, - SuccessURL: checkoutSuccessURL(h.ctx.Config), - CancelURL: checkoutCancelURLForIntent(h.ctx.Config, req.PlanCode, req.BillingInterval, activeTenantID), - Locale: req.Locale, - UserID: user.ID, - TenantID: activeTenantID, - PlanCode: req.PlanCode, - PlanName: planNameForCode(req.PlanCode), - BillingInterval: req.BillingInterval, - Jurisdiction: strings.TrimSpace(strings.ToUpper(h.ctx.Config.CloudJurisdiction)), + customerID := strings.TrimSpace(account.StripeCustomerID) + if customerID == "" { + customerID, err = h.stripe.CreateCustomer(r.Context(), createCustomerInput{ + Email: strings.TrimSpace(strings.ToLower(user.Email)), + Name: stripeCustomerName(user, team.Name), + UserID: user.ID, + TenantID: activeTenantID, + PlanCode: req.PlanCode, + Jurisdiction: strings.TrimSpace(strings.ToUpper(h.ctx.Config.CloudJurisdiction)), + IdempotencyKey: stripeCustomerCreateIdempotencyKey(user.ID, activeTenantID, user.Email), }) if err != nil { - shared.LoggerFromContext(r.Context()).Error("Failed to create Stripe upgrade checkout session", slog.Group("stripe", stripeErrorLogAttrs(err)...)) + shared.LoggerFromContext(r.Context()).Error("Failed to create Stripe customer for billing checkout", slog.Group("stripe", stripeErrorLogAttrs(err)...)) http.Error(w, "Unable to start checkout", http.StatusBadGateway) - return + return nil } + } - if err := h.ctx.Store.UpsertCloudBillingAccount(r.Context(), database.CloudBillingAccount{ - TenantID: activeTenantID, - PlanCode: database.CloudPlanFree, - PlanName: planNameForCode(database.CloudPlanFree), - BillingInterval: req.BillingInterval, - SubscriptionStatus: subscriptionStatusPending, - StripeCustomerID: customerID, - StripeSubscriptionID: "", - StripePriceID: priceID, - }); err != nil { - shared.LoggerFromContext(r.Context()).Error("Failed to persist Stripe upgrade checkout metadata", "error", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - if _, err := h.ctx.Store.RecordCloudConversionEvent(r.Context(), database.CloudConversionEvent{ - TenantID: activeTenantID, - EventName: database.CloudConversionCheckoutStarted, - PlanCode: req.PlanCode, - BillingInterval: req.BillingInterval, - DedupeKey: activeTenantID.String() + ":checkout:" + session.ID, - }); err != nil { - shared.LoggerFromContext(r.Context()).Warn("Failed to record checkout conversion", "error", err, "team_id", activeTenantID, "checkout_session_id", session.ID) + if account.StripeCustomerID != customerID { + account.StripeCustomerID = customerID + if err := h.ctx.Store.UpsertCloudBillingAccount(r.Context(), *account); err != nil { + http.Error(w, "Unable to persist billing customer", http.StatusServiceUnavailable) + return nil } + } + + session, err := h.reuseOrCreateCheckout(r.Context(), createCheckoutSessionInput{ + CustomerID: customerID, + PriceID: priceID, + SuccessURL: checkoutSuccessURL(h.ctx.Config), + CancelURL: checkoutCancelURLForIntent(h.ctx.Config, req.PlanCode, req.BillingInterval, activeTenantID), + Locale: req.Locale, + UserID: user.ID, + TenantID: activeTenantID, + PlanCode: req.PlanCode, + PlanName: planNameForCode(req.PlanCode), + BillingInterval: req.BillingInterval, + Jurisdiction: strings.TrimSpace(strings.ToUpper(h.ctx.Config.CloudJurisdiction)), + }) + if errors.Is(err, errCheckoutConflict) { + http.Error(w, errCheckoutConflict.Error(), http.StatusConflict) + return nil + } + if err != nil { + shared.LoggerFromContext(r.Context()).Error("Failed to create Stripe upgrade checkout session", slog.Group("stripe", stripeErrorLogAttrs(err)...)) + http.Error(w, "Unable to start checkout", http.StatusBadGateway) + return nil + } - shared.WriteJSON(r.Context(), w, http.StatusOK, billingCheckoutSessionResponse{URL: session.URL}) + if err := h.ctx.Store.MarkCloudBillingCheckoutPending(r.Context(), *account, req.BillingInterval, priceID); err != nil { + shared.LoggerFromContext(r.Context()).Error("Failed to persist Stripe upgrade checkout metadata", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return nil } + if _, err := h.ctx.Store.RecordCloudConversionEvent(r.Context(), database.CloudConversionEvent{ + TenantID: activeTenantID, + EventName: database.CloudConversionCheckoutStarted, + PlanCode: req.PlanCode, + BillingInterval: req.BillingInterval, + DedupeKey: activeTenantID.String() + ":checkout:" + session.ID, + }); err != nil { + shared.LoggerFromContext(r.Context()).Warn("Failed to record checkout conversion", "error", err, "team_id", activeTenantID, "checkout_session_id", session.ID) + } + + return session } type billingStateResponse struct { @@ -323,6 +355,10 @@ func (h *handler) handleGetBillingState() http.HandlerFunc { http.Error(w, "Unable to resolve active team", http.StatusBadRequest) return } + if !shared.CheckAgentTeam(w, r, teamID) { + return + } + state := billingStateResponse{TeamID: teamID, PlanCode: database.CloudPlanFree, SubscriptionStatus: database.CloudSubscriptionStatusFree} account, err := h.ctx.Store.GetCloudBillingAccount(r.Context(), teamID) if err != nil && !errors.Is(err, database.ErrCloudBillingAccountNotFound) { diff --git a/server/cloud/handlers_billing_test.go b/server/cloud/handlers_billing_test.go index 028210dd..35959f92 100644 --- a/server/cloud/handlers_billing_test.go +++ b/server/cloud/handlers_billing_test.go @@ -30,6 +30,9 @@ import ( ) type fakeStripeClient struct { + afterCheckoutList func() + checkoutCalls int + sessions []*checkoutSessionOutput lastCustomerInput *createCustomerInput lastCheckoutInput *createCheckoutSessionInput lastPortalInput *createPortalSessionInput @@ -50,14 +53,17 @@ func (f *fakeStripeClient) CreateCustomer(_ context.Context, input createCustome } func (f *fakeStripeClient) CreateCheckoutSession(_ context.Context, input createCheckoutSessionInput) (*checkoutSessionOutput, error) { + f.checkoutCalls++ f.lastCheckoutInput = &input if f.createCheckoutErr != nil { return nil, f.createCheckoutErr } - return &checkoutSessionOutput{ - ID: "cs_test", - URL: "https://checkout.stripe.test/session", - }, nil + session := &checkoutSessionOutput{PriceID: input.PriceID, ID: "cs_test", URL: "https://checkout.stripe.test/session", Status: "open", PaymentStatus: "unpaid", CustomerID: input.CustomerID, TenantID: input.TenantID.String(), PlanCode: input.PlanCode, BillingInterval: input.BillingInterval, Currency: "eur", AmountSubtotal: 1200, AmountTotal: 1200} + if f.checkoutCalls > 1 { + session.ID = fmt.Sprintf("cs_test_%d", f.checkoutCalls) + } + f.sessions = append([]*checkoutSessionOutput{session}, f.sessions...) + return session, nil } func (f *fakeStripeClient) CreatePortalSession(_ context.Context, input createPortalSessionInput) (*portalSessionOutput, error) { diff --git a/server/cloud/handlers_oss.go b/server/cloud/handlers_oss.go index 37f6d0f3..d078d7b9 100644 --- a/server/cloud/handlers_oss.go +++ b/server/cloud/handlers_oss.go @@ -13,3 +13,6 @@ func Register(_ *http.ServeMux, _ *shared.Context) {} // PlansAvailable is false in builds without Cloud billing routes. func PlansAvailable(_ *config.Config) bool { return false } + +// CommerceAvailable is false without the Cloud billing implementation. +func CommerceAvailable(_ *config.Config) bool { return false } diff --git a/server/cloud/plan_catalog_handoff_test.go b/server/cloud/plan_catalog_handoff_test.go index bac3b9c7..b68590ac 100644 --- a/server/cloud/plan_catalog_handoff_test.go +++ b/server/cloud/plan_catalog_handoff_test.go @@ -156,3 +156,18 @@ func readPlanCatalog(t *testing.T, h *handler, provider *fakeStripeClient) ([]ap } return plans, body } + +func TestPlanCatalogMarkdownKeepsAuthoritativePrices(t *testing.T) { + h, fake := planCatalogTestHandler() + h.ctx.Config.AIEnabled = true + req := httptest.NewRequest(http.MethodGet, "/api/cloud/plans", nil) + req.Header.Set("Accept", "text/markdown") + w := httptest.NewRecorder() + h.handleListCloudPlans().ServeHTTP(w, req) + if w.Code != 200 || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/markdown") || !strings.Contains(w.Body.String(), "17500 EUR minor units") || !strings.Contains(w.Body.String(), "pro:monthly") || !strings.Contains(w.Body.String(), "purchase=review") { + t.Fatalf("catalog=%d %s", w.Code, w.Body.String()) + } + if fake.checkoutCalls != 0 || fake.lastCustomerInput != nil { + t.Fatal("Markdown catalog mutated billing") + } +} diff --git a/server/cloud/stripe_checkout_sessions.go b/server/cloud/stripe_checkout_sessions.go new file mode 100644 index 00000000..aa29d844 --- /dev/null +++ b/server/cloud/stripe_checkout_sessions.go @@ -0,0 +1,103 @@ +//go:build billing + +package cloud + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + + stripe "github.com/stripe/stripe-go/v87" +) + +func checkoutSessionFromStripe(s *stripe.CheckoutSession) *checkoutSessionOutput { + out := &checkoutSessionOutput{PriceID: s.Metadata["price_id"], ID: s.ID, URL: s.URL, Status: string(s.Status), PaymentStatus: string(s.PaymentStatus), TenantID: s.Metadata["tenant_id"], PlanCode: s.Metadata["plan_code"], BillingInterval: s.Metadata["billing_interval"], Currency: string(s.Currency), AmountSubtotal: s.AmountSubtotal, AmountTotal: s.AmountTotal, ExpiresAt: s.ExpiresAt} + if s.Customer != nil { + out.CustomerID = s.Customer.ID + } + if s.Subscription != nil { + out.SubscriptionID = s.Subscription.ID + out.SubscriptionStatus = string(s.Subscription.Status) + } + return out +} + +func (c *stripeSDKClient) GetCheckoutSession(ctx context.Context, id string) (*checkoutSessionOutput, error) { + params := &stripe.CheckoutSessionRetrieveParams{} + params.AddExpand("subscription") + setStripeVersionHeader(¶ms.Params) + s, err := c.client.V1CheckoutSessions.Retrieve(ctx, id, params) + if err != nil { + return nil, fmt.Errorf("retrieve checkout: %w", err) + } + return checkoutSessionFromStripe(s), nil +} + +func (c *stripeSDKClient) ListCheckoutSessions(ctx context.Context, customer string) ([]*checkoutSessionOutput, error) { + params := &stripe.CheckoutSessionListParams{Customer: new(customer)} + params.AddExpand("data.subscription") + params.Limit = new(int64(100)) + sessions := make([]*checkoutSessionOutput, 0) + for s, err := range c.client.V1CheckoutSessions.List(ctx, params).All(ctx) { + if err != nil { + return nil, fmt.Errorf("list checkout: %w", err) + } + if len(sessions) >= 1000 { + return nil, errors.New("checkout history requires operator review") + } + sessions = append(sessions, checkoutSessionFromStripe(s)) + } + return sessions, nil +} + +func (c *stripeSDKClient) ExpireCheckoutSession(ctx context.Context, id, key string) (*checkoutSessionOutput, error) { + params := &stripe.CheckoutSessionExpireParams{} + setStripeVersionHeader(¶ms.Params) + setStripeIdempotencyKey(¶ms.Params, key) + s, err := c.client.V1CheckoutSessions.Expire(ctx, id, params) + if err != nil { + return nil, fmt.Errorf("expire checkout: %w", err) + } + return checkoutSessionFromStripe(s), nil +} + +// Stripe owns durable session state; both browser and protocol purchases share it. +func (h *handler) reuseOrCreateCheckout(ctx context.Context, input createCheckoutSessionInput) (*checkoutSessionOutput, error) { + sessions, err := h.stripe.ListCheckoutSessions(ctx, input.CustomerID) + if err != nil { + return nil, err + } + previous := "initial" + var pending *checkoutSessionOutput + for _, s := range sessions { + if s.TenantID != input.TenantID.String() { + continue + } + if previous == "initial" { + previous = s.ID + } + // Only Stripe's terminal subscription state makes a historical payment safe to replace. + if s.Status == "complete" && s.SubscriptionStatus != string(stripe.SubscriptionStatusCanceled) && s.SubscriptionStatus != string(stripe.SubscriptionStatusIncompleteExpired) { + return nil, errCheckoutConflict + } + if s.Status != "open" { + continue + } + if s.PlanCode != input.PlanCode || s.BillingInterval != input.BillingInterval || (s.PriceID != "" && s.PriceID != input.PriceID) { + return nil, errCheckoutConflict + } + if pending != nil && pending.ID != s.ID { + return nil, errCheckoutConflict + } + pending = s + } + if pending != nil { + return pending, nil + } + // One key for the next team purchase, independent of browser/protocol and plan. + input.IdempotencyKey = fmt.Sprintf("checkout_%x", sha256.Sum256([]byte(input.TenantID.String()+":"+previous))) + return h.stripe.CreateCheckoutSession(ctx, input) +} + +var errCheckoutConflict = errors.New("a checkout already exists; finish or cancel it before starting another") diff --git a/server/cloud/stripe_checkout_sessions_test.go b/server/cloud/stripe_checkout_sessions_test.go new file mode 100644 index 00000000..79b85cf2 --- /dev/null +++ b/server/cloud/stripe_checkout_sessions_test.go @@ -0,0 +1,136 @@ +//go:build billing + +package cloud + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" + stripe "github.com/stripe/stripe-go/v87" +) + +func TestReuseCheckoutUsesCurrentSubscriptionState(t *testing.T) { + for _, status := range []string{"canceled", "incomplete_expired", "active", "past_due", "unpaid", "incomplete", ""} { + t.Run(status, func(t *testing.T) { + h, f := planCatalogTestHandler() + teamID := uuid.New() + f.sessions = []*checkoutSessionOutput{ + {ID: "cs_newer_history", TenantID: teamID.String(), Status: "complete", SubscriptionID: "sub_newer", SubscriptionStatus: status}, + {ID: "cs_older_history", TenantID: teamID.String(), Status: "complete", SubscriptionID: "sub_older", SubscriptionStatus: status}, + } + input := createCheckoutSessionInput{CustomerID: "cus_test", TenantID: teamID, PlanCode: "pro", BillingInterval: "monthly", PriceID: "pro_month"} + first, err := h.reuseOrCreateCheckout(t.Context(), input) + if status != "canceled" && status != "incomplete_expired" { + if !errors.Is(err, errCheckoutConflict) || f.checkoutCalls != 0 { + t.Fatalf("live/unknown history created checkout: %v calls=%d", err, f.checkoutCalls) + } + return + } + if err != nil || f.checkoutCalls != 1 { + t.Fatalf("terminal history prevented resubscribe: %v calls=%d", err, f.checkoutCalls) + } + again, err := h.reuseOrCreateCheckout(t.Context(), input) + if err != nil || again.ID != first.ID || f.checkoutCalls != 1 { + t.Fatalf("pending resubscribe retry=%v calls=%d", err, f.checkoutCalls) + } + }) + } +} + +func TestReuseCheckoutChecksAllCustomerSessions(t *testing.T) { + h, f := planCatalogTestHandler() + teamID := uuid.New() + f.sessions = []*checkoutSessionOutput{ + {ID: "cs_pending", TenantID: teamID.String(), Status: "open", PlanCode: "pro", BillingInterval: "monthly", PriceID: "pro_month"}, + {ID: "cs_unreconciled", TenantID: teamID.String(), Status: "complete", SubscriptionID: "sub_active", SubscriptionStatus: "active"}, + } + input := createCheckoutSessionInput{CustomerID: "cus_test", TenantID: teamID, PlanCode: "pro", BillingInterval: "monthly", PriceID: "pro_month"} + if _, err := h.reuseOrCreateCheckout(t.Context(), input); !errors.Is(err, errCheckoutConflict) { + t.Fatalf("pending session hid active subscription: %v", err) + } + f.sessions[1] = &checkoutSessionOutput{ID: "cs_competing", TenantID: teamID.String(), Status: "open", PlanCode: "business", BillingInterval: "monthly", PriceID: "business_month"} + if _, err := h.reuseOrCreateCheckout(t.Context(), input); !errors.Is(err, errCheckoutConflict) { + t.Fatalf("pending session hid competing checkout: %v", err) + } +} + +func TestStripeCheckoutSessionWireContract(t *testing.T) { + teamID := uuid.New() + calls := 0 + fixture := fmt.Sprintf(`{"id":"cs_wire","object":"checkout.session","url":"https://checkout.stripe.test/wire","mode":"subscription","status":"open","payment_status":"unpaid","customer":"cus_wire","currency":"eur","amount_subtotal":1200,"amount_total":1200,"metadata":{"tenant_id":%q,"plan_code":"pro","billing_interval":"monthly","price_id":"price_wire"}}`, teamID.String()) + historical := strings.Replace(fixture, `"status":"open"`, `"status":"complete","subscription":{"id":"sub_old","object":"subscription","status":"canceled"}`, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if got := r.Header.Get("Stripe-Version"); got != stripeAPIVersion { + t.Errorf("Stripe-Version=%q", got) + } + w.Header().Set("Content-Type", "application/json") + switch r.Method + " " + r.URL.Path { + case "POST /v1/checkout/sessions": + if err := r.ParseForm(); err != nil { + t.Error(err) + } + if r.Header.Get("Idempotency-Key") != "wire-key" || r.Form.Get("mode") != "subscription" || r.Form.Get("line_items[0][price]") != "price_wire" || r.Form.Get("line_items[0][quantity]") != "1" || r.Form.Get("metadata[tenant_id]") != teamID.String() { + t.Errorf("unsafe create request: %v", r.Form) + } + _, _ = w.Write([]byte(fixture)) + case "GET /v1/checkout/sessions": + if r.URL.Query().Get("customer") != "cus_wire" { + t.Error("unscoped customer list") + } + if r.URL.Query().Get("expand[0]") != "data.subscription" { + t.Errorf("subscription status not expanded: %s", r.URL.RawQuery) + } + _, _ = fmt.Fprintf(w, `{"object":"list","data":[%s],"has_more":false,"url":"/v1/checkout/sessions"}`, historical) + case "GET /v1/checkout/sessions/cs_wire": + if r.URL.Query().Get("expand[0]") != "subscription" { + t.Errorf("subscription status not expanded: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(historical)) + case "POST /v1/checkout/sessions/cs_wire/expire": + if r.Header.Get("Idempotency-Key") != "cancel-key" { + t.Error("cancel idempotency missing") + } + _, _ = w.Write([]byte(`{"id":"cs_wire","object":"checkout.session","status":"expired","customer":"cus_wire"}`)) + default: + t.Errorf("unexpected Stripe operation %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + backend := stripe.GetBackendWithConfig(stripe.APIBackend, &stripe.BackendConfig{URL: new(server.URL), HTTPClient: server.Client(), MaxNetworkRetries: new(int64(0))}) + client := &stripeSDKClient{client: stripe.NewClient("sk_fixture", stripe.WithBackends(&stripe.Backends{API: backend}))} + session, err := client.CreateCheckoutSession(t.Context(), createCheckoutSessionInput{IdempotencyKey: "wire-key", CustomerID: "cus_wire", PriceID: "price_wire", TenantID: teamID, PlanCode: "pro", BillingInterval: "monthly"}) + if err != nil { + t.Fatal(err) + } + if session.CustomerID != "cus_wire" || session.TenantID != teamID.String() || session.Status != "open" || session.PriceID != "price_wire" || session.AmountTotal != 1200 { + t.Fatalf("bad session projection: %+v", session) + } + sessions, err := client.ListCheckoutSessions(t.Context(), "cus_wire") + if err != nil || len(sessions) != 1 { + t.Fatalf("list=%v err=%v", sessions, err) + } + if sessions[0].SubscriptionID != "sub_old" || sessions[0].SubscriptionStatus != "canceled" { + t.Fatalf("missing current subscription state: %+v", sessions[0]) + } + loaded, err := client.GetCheckoutSession(t.Context(), "cs_wire") + if err != nil { + t.Fatal(err) + } + if loaded.SubscriptionStatus != "canceled" { + t.Fatalf("retrieve lost subscription state: %+v", loaded) + } + expired, err := client.ExpireCheckoutSession(t.Context(), "cs_wire", "cancel-key") + if err != nil || expired.Status != "expired" { + t.Fatalf("expire=%v err=%v", expired, err) + } + if calls != 4 { + t.Fatalf("Stripe requests=%d", calls) + } +} diff --git a/server/cloud/stripe_client.go b/server/cloud/stripe_client.go index 8364354f..f7de7ede 100644 --- a/server/cloud/stripe_client.go +++ b/server/cloud/stripe_client.go @@ -66,11 +66,13 @@ func (c *stripeSDKClient) CreateCheckoutSession(ctx context.Context, input creat }, } setStripeVersionHeader(¶ms.Params) + setStripeIdempotencyKey(¶ms.Params, input.IdempotencyKey) params.Locale = stripe.String(input.Locale) params.AddMetadata("user_id", input.UserID.String()) params.AddMetadata("tenant_id", input.TenantID.String()) params.AddMetadata("plan_code", input.PlanCode) params.AddMetadata("plan_name", input.PlanName) + params.AddMetadata("price_id", input.PriceID) params.AddMetadata("billing_interval", input.BillingInterval) params.AddMetadata("jurisdiction", input.Jurisdiction) @@ -78,10 +80,7 @@ func (c *stripeSDKClient) CreateCheckoutSession(ctx context.Context, input creat if err != nil { return nil, fmt.Errorf("create checkout session: %w", err) } - return &checkoutSessionOutput{ - ID: created.ID, - URL: created.URL, - }, nil + return checkoutSessionFromStripe(created), nil } func (c *stripeSDKClient) CreatePortalSession(ctx context.Context, input createPortalSessionInput) (*portalSessionOutput, error) { diff --git a/server/discovery.go b/server/discovery.go index 1d29e07c..73638c24 100644 --- a/server/discovery.go +++ b/server/discovery.go @@ -55,11 +55,33 @@ func (s *Server) discoveryBase() *url.URL { func (s *Server) registerDiscoveryRoutes(mux *http.ServeMux) { register := func(route string, handler http.HandlerFunc) { - mux.HandleFunc("GET "+route, s.ctx.Handler(shared.HandlerConfig{RateLimiter: s.ctx.ApiLimiter}, handler)) + wrapped := s.ctx.Handler(shared.HandlerConfig{RateLimiter: s.ctx.ApiLimiter}, handler) + mux.HandleFunc("GET "+route, wrapped) + if route != "/robots.txt" { + mux.HandleFunc("OPTIONS "+route, wrapped) + } } register("/robots.txt", s.discoveryRobots) + for _, route := range []string{"/.well-known/agent-skills/index.json", "/.well-known/skills/index.json"} { + register(route, s.agentDiscovery(s.discoveryResponse("application/json", func(base *url.URL) any { + index, err := skills.PublicIndex(base.String()) + if err != nil { + return nil + } + return index + }))) + } + register("/.well-known/poppy.json", s.agentDiscovery(s.discoveryResponse("application/json", func(base *url.URL) any { + if base.Scheme != "https" { + return nil + } + return map[string]any{"protocol_version": "0.2", "organization": map[string]string{"name": "HitKeep", "domain": base.Hostname()}, "web": map[string]any{}} + }))) + register("/.well-known/agent-card.json", s.agentDiscovery(s.discoveryResponse("application/json", s.a2aCard))) + register("/auth.md", s.agentDiscovery(s.discoveryMarkdown(s.authMarkdown))) + register("/llms.txt", s.agentDiscovery(s.discoveryMarkdown(s.llmsMarkdown))) for _, route := range openAPIDiscoveryPaths { - register(route, s.discoveryResponse("application/json", func(base *url.URL) any { return system.OpenAPISpecV1(base.String()) })) + register(route, s.discoveryResponse("application/json", func(base *url.URL) any { return system.OpenAPISpecV1ForRuntime(base.String(), s.ctx) })) } register("/.well-known/api-catalog", s.discoveryResponse(apiCatalogMediaType, s.apiCatalog)) register("/.well-known/ai-catalog.json", s.discoveryResponse("application/ai-catalog+json", func(base *url.URL) any { return map[string]any{"specVersion": "1.0", "entries": s.agentEntries(base)} })) @@ -69,11 +91,14 @@ func (s *Server) registerDiscoveryRoutes(mux *http.ServeMux) { register(strings.TrimRight(s.conf.MCPPath, "/")+"/server-card", card) register("/.well-known/mcp/server-card.json", s.discoveryResponse("application/json", s.legacyMCPServerCard)) register("/skills/{skill}/{file...}", func(w http.ResponseWriter, r *http.Request) { - if s.discoveryBase() == nil { + if !s.agentAvailable() || s.discoveryBase() == nil { http.NotFound(w, r) return } data, err := skills.PublicFile(r.PathValue("skill") + "/" + r.PathValue("file")) + if r.PathValue("file") == "skill.zip" { + data, err = skills.PublicArchive(r.PathValue("skill")) + } if err != nil { http.NotFound(w, r) return @@ -82,12 +107,10 @@ func (s *Server) registerDiscoveryRoutes(mux *http.ServeMux) { if r.PathValue("file") == "SKILL.md" { media = "application/agent-skills+md; charset=utf-8" } - s.discoveryHeaders(w, media) - w.Header().Set("Content-Length", strconv.Itoa(len(data))) - if r.Method != http.MethodHead { - // #nosec G705 -- PublicFile returns only allowlisted embedded Markdown; non-HTML media and nosniff are set above. - _, _ = w.Write(data) + if r.PathValue("file") == "skill.zip" { + media = "application/zip" } + s.writeDiscovery(w, r, media, data) }) } @@ -109,11 +132,7 @@ func (s *Server) discoveryResponse(media string, body func(*url.URL) any) http.H http.Error(w, "Unable to encode discovery metadata", http.StatusInternalServerError) return } - s.discoveryHeaders(w, media) - w.Header().Set("Content-Length", strconv.Itoa(len(data))) - if r.Method != http.MethodHead { - _, _ = w.Write(data) - } + s.writeDiscovery(w, r, media, data) } } @@ -121,7 +140,9 @@ func (s *Server) discoveryHeaders(w http.ResponseWriter, media string) { w.Header().Set("Content-Type", media) w.Header().Set("Cache-Control", discoveryCacheControl) w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD") + w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, If-None-Match") + w.Header().Set("Access-Control-Expose-Headers", "ETag, Link") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Link", s.discoveryLinks()) } @@ -136,6 +157,14 @@ func (s *Server) discoveryLinks() string { fmt.Sprintf(`<%s>; rel="related"; type="application/ai-catalog+json"`, appurl.Path(base.String(), "/.well-known/ai-catalog.json")), fmt.Sprintf(`<%s>; rel="service-desc"; type="application/json"`, appurl.Path(base.String(), "/.well-known/ard.json")), } + if s.agentAvailable() { + for _, route := range []string{"/.well-known/agent-skills/index.json", "/auth.md", "/llms.txt"} { + links = append(links, fmt.Sprintf(`<%s>; rel="related"`, appurl.Path(base.String(), route))) + } + } + if s.ctx.A2AAvailable() { + links = append(links, fmt.Sprintf(`<%s>; rel="related"; type="application/json"; title="A2A agent card"`, appurl.Path(base.String(), "/.well-known/agent-card.json"))) + } if s.ctx.OAuthProvider != nil { links = append(links, fmt.Sprintf(`<%s>; rel="related"; type="application/json"; title="OAuth authorization server metadata"`, base.Scheme+"://"+base.Host+s.ctx.OAuthProvider.DiscoveryPaths()[0])) } @@ -165,6 +194,9 @@ func (s *Server) authentication(base *url.URL) map[string]any { if s.ctx.OAuthProvider != nil { auth["authorization_server"] = base.Scheme + "://" + base.Host + s.ctx.OAuthProvider.DiscoveryPaths()[0] auth["api_resource_metadata"] = s.ctx.OAuthProvider.ResourceMetadataURL("api") + if s.conf.AIEnabled && s.ctx.OAuthProvider.Ready() { + auth["delegated_actions"] = map[string]any{"resource": s.ctx.OAuthProvider.Resource("api"), "contract": appurl.Path(base.String(), "/openapi.json"), "scope_field": "x-hitkeep-oauth-scope", "consent": "Explicit team and site consent; billing-only consent does not require sites. Current permissions are checked on every request."} + } if s.mcpAvailable() { auth["mcp_resource_metadata"] = s.ctx.OAuthProvider.ResourceMetadataURL("mcp") } @@ -264,6 +296,9 @@ func (s *Server) apiCatalog(base *url.URL) any { if cloudhandlers.PlansAvailable(s.conf) { linkset = append(linkset, map[string]any{"anchor": appurl.Path(base.String(), "/api/cloud/plans"), "service-doc": []any{map[string]string{"href": "https://hitkeep.com/pricing/", "type": "text/html"}}}) } + if s.ctx.A2AAvailable() { + linkset = append(linkset, map[string]any{"anchor": appurl.Path(base.String(), "/api/a2a"), "service-desc": []any{map[string]string{"href": appurl.Path(base.String(), "/.well-known/agent-card.json"), "type": "application/json"}}}) + } return map[string]any{"linkset": linkset} } @@ -277,12 +312,20 @@ type discoveryEntry struct { } func (s *Server) agentEntries(base *url.URL) []discoveryEntry { - entries := []discoveryEntry{{"", "HitKeep REST API", "application/vnd.oai.openapi+json", appurl.Path(base.String(), "/openapi.json"), "HTTP API with scoped analytics access and human-administered application operations.", []string{"How can I read approved HitKeep analytics?", "Where is the HitKeep API authentication contract?"}}} + entries := []discoveryEntry{{"", "HitKeep REST API", "application/vnd.oai.openapi+json", appurl.Path(base.String(), "/openapi.json"), "HTTP API with scoped analytics and explicitly consented OAuth product actions where enabled.", []string{"How can I read approved HitKeep analytics?", "Where is the HitKeep API authentication contract?"}}} if s.mcpAvailable() { entries = append(entries, discoveryEntry{"", "HitKeep Analytics MCP", serverCardMediaType, appurl.Path(base.String(), strings.TrimRight(s.conf.MCPPath, "/")+"/server-card"), "Read-only aggregate analytics; server card discovery is experimental.", []string{"How can my assistant read approved analytics?", "Where can I connect a scoped HitKeep MCP client?"}}) } - for _, skill := range skills.AnalyticsProcedures() { - entries = append(entries, discoveryEntry{"", skill.Title, "application/agent-skills+md", appurl.Path(base.String(), "/skills/"+skill.Name+"/SKILL.md"), "Official product analytics instructions; installation does not grant data access.", []string{"How do I use " + skill.Name + " with approved analytics?", "Where are the " + skill.Name + " procedure and privacy boundaries?"}}) + if s.agentAvailable() { + for _, skill := range skills.AnalyticsProcedures() { + entries = append(entries, discoveryEntry{"", skill.Title, "application/agent-skills+md", appurl.Path(base.String(), "/skills/"+skill.Name+"/SKILL.md"), "Official product analytics instructions; installation does not grant data access.", []string{"How do I use " + skill.Name + " with approved analytics?", "Where are the " + skill.Name + " procedure and privacy boundaries?"}}) + } + } + if s.ctx.A2AAvailable() { + entries = append(entries, discoveryEntry{"", "HitKeep synchronous analytics agent", "application/a2a+json", appurl.Path(base.String(), "/.well-known/agent-card.json"), "A2A 1.0 structured read-only analytics using API-resource OAuth, current site permissions and explicit consent.", []string{"How can an agent read bounded aggregate analytics?", "Which analytics tools accept structured A2A messages?"}}) + } + if s.agentAvailable() && cloudhandlers.CommerceAvailable(s.conf) && s.ctx.Store != nil && (s.ctx.Cluster == nil || s.ctx.Cluster.IsLeader()) { + entries = append(entries, discoveryEntry{"", "HitKeep Cloud checkout", "application/vnd.oai.openapi+json", appurl.Path(base.String(), "/openapi.json") + "#/paths/~1api~1cloud~1commerce~1acp~1checkout_sessions", "ACP/UCP checkout adapters require explicit billing:write OAuth consent or an authenticated dashboard browser session, current billing administration and human Stripe Checkout approval. No autonomous payment rail.", []string{"How can a billing administrator open hosted checkout?", "How does checkout require human payment approval?"}}) } for i := range entries { entries[i].Identifier = entries[i].URL @@ -317,7 +360,7 @@ func (s *Server) ardEntries(base *url.URL) []discoveryEntry { // Discovery well-known locations remain reachable at the origin for prefixed instances. func (s *Server) isDiscoveryRoot(requestPath string) bool { switch requestPath { - case "/robots.txt", "/.well-known/api-catalog", "/.well-known/ai-catalog.json", "/.well-known/ard.json", "/.well-known/integrations.json", "/.well-known/mcp/server-card.json": + case "/.well-known/agent-card.json", "/auth.md", "/llms.txt", "/.well-known/agent-skills/index.json", "/.well-known/skills/index.json", "/.well-known/poppy.json", "/robots.txt", "/.well-known/api-catalog", "/.well-known/ai-catalog.json", "/.well-known/ard.json", "/.well-known/integrations.json", "/.well-known/mcp/server-card.json": return true default: return false @@ -360,9 +403,30 @@ func (s *Server) discoveryRobots(w http.ResponseWriter, r *http.Request) { text.WriteString("Allow: /.well-known/mcp/server-card.json$\n") } } - for _, skill := range skills.AnalyticsProcedures() { - allow("/skills/" + skill.Name + "/SKILL.md") - allow("/skills/" + skill.Name + "/references/procedure.md") + if s.agentAvailable() { + for _, route := range []string{"/.well-known/agent-skills/index.json", "/.well-known/skills/index.json", "/auth.md", "/llms.txt"} { + allow(route) + if base.Path != "" { + text.WriteString("Allow: " + route + "$\n") + } + } + if base.Scheme == "https" { + allow("/.well-known/poppy.json") + if base.Path != "" { + text.WriteString("Allow: /.well-known/poppy.json$\n") + } + } + for _, skill := range skills.AnalyticsProcedures() { + allow("/skills/" + skill.Name + "/skill.zip") + allow("/skills/" + skill.Name + "/SKILL.md") + allow("/skills/" + skill.Name + "/references/procedure.md") + } + } + if s.ctx.A2AAvailable() { + allow("/.well-known/agent-card.json") + if base.Path != "" { + text.WriteString("Allow: /.well-known/agent-card.json$\n") + } } if s.ctx.OAuthProvider != nil { for _, route := range s.ctx.OAuthProvider.DiscoveryPaths() { @@ -382,3 +446,71 @@ func (s *Server) discoveryRobots(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(data) } } + +func (s *Server) agentAvailable() bool { return s.conf != nil && s.conf.AIEnabled } + +func (s *Server) agentDiscovery(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !s.agentAvailable() { + http.NotFound(w, r) + return + } + next(w, r) + } +} + +func (s *Server) writeDiscovery(w http.ResponseWriter, r *http.Request, media string, data []byte) { + s.discoveryHeaders(w, media) + etag := fmt.Sprintf(`"%x"`, sha256.Sum256(data)) + w.Header().Set("ETag", etag) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + for tag := range strings.SplitSeq(r.Header.Get("If-None-Match"), ",") { + tag = strings.TrimPrefix(strings.TrimSpace(tag), "W/") + if tag == etag || tag == "*" { + w.WriteHeader(http.StatusNotModified) + return + } + } + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + if r.Method != http.MethodHead { + // #nosec G705 -- Only embedded public artifacts or server-owned discovery text; non-HTML media and nosniff are set above. + _, _ = w.Write(data) + } +} + +func (s *Server) discoveryMarkdown(body func(*url.URL) string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + base := s.discoveryBase() + if base == nil { + http.NotFound(w, r) + return + } + s.writeDiscovery(w, r, "text/markdown; charset=utf-8", []byte(body(base))) + } +} + +func (s *Server) authMarkdown(base *url.URL) string { + text := "# HitKeep authentication\n\nUse a scoped API-client bearer token created by the account owner at " + appurl.Path(base.String(), "/integration/api-clients") + ". Send it in the Authorization: Bearer header. Never send dashboard cookies to MCP or ask for passwords.\n\n" + if s.ctx.OAuthProvider != nil { + text += "For OAuth, discover authorization server metadata at " + base.Scheme + "://" + base.Host + s.ctx.OAuthProvider.DiscoveryPaths()[0] + ". Use the existing authorization code flow with PKCE S256, a registered client ID or HTTPS client metadata document, the exact resource and least required scopes. The user signs in and explicitly consents to sites. Do not reuse tokens across resources. API resource metadata: " + s.ctx.OAuthProvider.ResourceMetadataURL("api") + ".\n\n" + text += "External assistants can use user-delegated OAuth at ordinary REST operations marked x-hitkeep-oauth-scope in OpenAPI while AI is enabled. Request only the operation's scope: sites:read or analytics:read for approved reads; goals:write for goals/funnels; annotations:write for timeline notes; sites:write for domain, retention and exclusions; opportunities:write for generation/status; billing:write for configured team Checkout. Each request intersects the grant with current user, team and site permissions. Billing-only consent selects a team without requiring sites; payment still needs human approval. No scopes grant credential issuance, impersonation, raw export or instance administration. Review and revoke grants in Settings → Connected applications.\n\n" + } + if s.mcpAvailable() { + text += "MCP endpoint: " + appurl.Path(base.String(), s.conf.MCPPath) + ". MCP accepts scoped bearer tokens for read-only aggregate analytics; OAuth requires sites:read and analytics:read plus explicitly consented sites.\n\n" + } + if s.ctx.A2AAvailable() { + text += "A2A 1.0 synchronous analytics: " + appurl.Path(base.String(), "/api/a2a/message:send") + ". Use the API OAuth resource with sites:read and analytics:read. Discover tool inputs at " + appurl.Path(base.String(), "/.well-known/agent-card.json") + ".\n\n" + } + return text + "API contract: " + appurl.Path(base.String(), "/openapi.json") + ". Skill installation and discovery grant no account or site access. No anonymous agent registration or Poppy session authentication is provided.\n" +} + +func (s *Server) llmsMarkdown(base *url.URL) string { + text := "# HitKeep\n\n> Privacy-first web analytics. This application requires approved account and site access.\n\n## Integration references\n\n- [API contract](" + appurl.Path(base.String(), "/openapi.json") + ")\n- [Authentication](" + appurl.Path(base.String(), "/auth.md") + ")\n- [Product analytics skills](" + appurl.Path(base.String(), "/.well-known/agent-skills/index.json") + ")\n- [Public documentation](https://hitkeep.com/llms.txt)\n" + if s.mcpAvailable() { + text += "- [Read-only MCP server](" + appurl.Path(base.String(), strings.TrimRight(s.conf.MCPPath, "/")+"/server-card") + ")\n" + } + return text +} diff --git a/server/discovery_test.go b/server/discovery_test.go index 79f84c58..42ec6246 100644 --- a/server/discovery_test.go +++ b/server/discovery_test.go @@ -26,7 +26,7 @@ func (c discoveryCluster) IsLeader() bool { return bool(c) } func (c discoveryCluster) GetLeaderAddr() string { return "" } func newDiscoveryServer() *Server { - conf := &config.Config{PublicURL: "https://analytics.example.test/tenant", MCPPath: "/tools/analytics", MCPEnabled: true, Version: "1.2.3"} + conf := &config.Config{PublicURL: "https://analytics.example.test/tenant", MCPPath: "/tools/analytics", MCPEnabled: true, AIEnabled: true, Version: "1.2.3"} ctx := &shared.Context{Config: conf, Store: new(database.Store), Logger: slog.New(slog.NewTextHandler(io.Discard, nil))} return &Server{conf: conf, ctx: ctx, indexHTML: []byte("dashboard")} } @@ -231,7 +231,7 @@ func TestPrefixedDiscoveryRoutingAndPrivateUnknownPaths(t *testing.T) { mux := http.NewServeMux() s.registerDiscoveryRoutes(mux) handler := s.stripPublicBasePath(mux) - for _, route := range []string{"/.well-known/api-catalog", "/tenant/.well-known/api-catalog", "/tenant/tools/analytics/server-card"} { + for _, route := range []string{"/.well-known/api-catalog", "/tenant/.well-known/api-catalog", "/tenant/tools/analytics/server-card", "/.well-known/agent-skills/index.json", "/tenant/.well-known/agent-skills/index.json", "/auth.md", "/tenant/auth.md", "/llms.txt", "/tenant/skills/hitkeep-analytics/skill.zip"} { rec := httptest.NewRecorder() handler.ServeHTTP(rec, httptest.NewRequest("GET", route, nil)) if rec.Code != 200 { @@ -379,3 +379,108 @@ func TestDiscoveryPublisherFormatsOnActualMetadata(t *testing.T) { }) } } + +func TestAgentDiscoveryFlagAndArtifacts(t *testing.T) { + s := newDiscoveryServer() + routes := []string{"/.well-known/agent-skills/index.json", "/.well-known/skills/index.json", "/skills/hitkeep-analytics/skill.zip", "/auth.md", "/llms.txt", "/.well-known/poppy.json"} + for _, enabled := range []bool{false, true} { + s.conf.AIEnabled = enabled + for _, route := range routes { + get := discoveryRequest(s, "GET", route) + head := discoveryRequest(s, "HEAD", route) + want := 404 + if enabled { + want = 200 + } + if get.Code != want || head.Code != want { + t.Fatalf("AI=%v %s GET=%d HEAD=%d", enabled, route, get.Code, head.Code) + } + if enabled && (head.Body.Len() != 0 || head.Header().Get("Content-Length") != get.Header().Get("Content-Length") || len(get.Header().Values("Access-Control-Allow-Origin")) != 1) { + t.Fatalf("headers: %v", get.Header()) + } + } + for _, route := range []string{"/.well-known/api-catalog", "/openapi.json", "/tools/analytics/server-card"} { + if got := discoveryRequest(s, "GET", route); got.Code != 200 { + t.Fatalf("existing surface disabled %s", route) + } + } + robots := discoveryRequest(s, "GET", "/robots.txt").Body.String() + links := s.discoveryLinks() + if strings.Contains(robots, "agent-skills") != enabled || strings.Contains(links, "agent-skills") != enabled { + t.Fatalf("AI=%v robots/links mismatch", enabled) + } + } + canonical := discoveryRequest(s, "GET", routes[0]) + alias := discoveryRequest(s, "GET", routes[1]) + if !bytes.Equal(canonical.Body.Bytes(), alias.Body.Bytes()) { + t.Fatal("alias differs") + } + var index skills.DiscoveryIndex + if err := json.Unmarshal(canonical.Body.Bytes(), &index); err != nil { + t.Fatal(err) + } + if len(index.Skills) != 5 { + t.Fatal("missing skills") + } + if strings.Contains(discoveryRequest(s, "GET", "/.well-known/poppy.json").Body.String(), `"auth"`) { + t.Fatal("advertised unsupported Poppy auth") + } +} + +func TestDiscoveryConditionalAndPreflight(t *testing.T) { + s := newDiscoveryServer() + mux := http.NewServeMux() + s.registerDiscoveryRoutes(mux) + get := discoveryRequest(s, "GET", "/.well-known/agent-skills/index.json") + if get.Header().Get("ETag") == "" { + t.Fatal("missing ETag") + } + req := httptest.NewRequest("GET", "/.well-known/agent-skills/index.json", nil) + req.Header.Set("If-None-Match", get.Header().Get("ETag")) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != 304 || rec.Body.Len() != 0 { + t.Fatalf("conditional=%d", rec.Code) + } + req = httptest.NewRequest("OPTIONS", "/.well-known/agent-skills/index.json", nil) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != 204 || rec.Header().Get("Access-Control-Allow-Headers") != "Content-Type, If-None-Match" { + t.Fatalf("preflight=%d %v", rec.Code, rec.Header()) + } + s.conf.AIEnabled = false + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != 404 { + t.Fatalf("disabled preflight=%d", rec.Code) + } +} + +func TestAgentDiscoveryMetadataMatchesFlag(t *testing.T) { + s := newDiscoveryServer() + for _, enabled := range []bool{true, false} { + s.conf.AIEnabled = enabled + response := discoveryRequest(s, "GET", "/openapi.json") + var spec struct { + Paths map[string]any `json:"paths"` + } + if err := json.Unmarshal(response.Body.Bytes(), &spec); err != nil { + t.Fatal(err) + } + for _, route := range []string{"/.well-known/agent-skills/index.json", "/auth.md"} { + if (spec.Paths[route] != nil) != enabled { + t.Fatalf("AI=%v advertised %s", enabled, route) + } + } + catalog := discoveryRequest(s, "GET", "/.well-known/ai-catalog.json").Body.String() + if strings.Contains(catalog, "/skills/") != enabled { + t.Fatalf("AI=%v skills catalog", enabled) + } + } + for _, route := range []string{"/skills/hitkeep-analytics%2F../skill.zip", "/skills/hitkeep-development/skill.zip", "/.well-known/agent-skills/missing.json"} { + response := discoveryRequest(s, "GET", route) + if response.Code == 200 { + t.Fatalf("unsafe/unknown route served %s", route) + } + } +} diff --git a/server/goals/handlers.go b/server/goals/handlers.go index 73832f18..a53899dd 100644 --- a/server/goals/handlers.go +++ b/server/goals/handlers.go @@ -27,47 +27,58 @@ type handler struct { func Register(mux *http.ServeMux, ctx *shared.Context) { h := &handler{ctx: ctx} mux.HandleFunc("GET /api/sites/{id}/goals", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetGoals())) mux.HandleFunc("GET /api/sites/{id}/goals/timeseries", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetGoalTimeseries())) mux.HandleFunc("POST /api/sites/{id}/goals", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "goals:write", SitePerm: authcore.PermSiteManageGoals, RateLimiter: ctx.ApiLimiter, }, h.handleCreateGoal())) mux.HandleFunc("PUT /api/sites/{id}/goals/{goalID}", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "goals:write", SitePerm: authcore.PermSiteManageGoals, RateLimiter: ctx.ApiLimiter, }, h.handleUpdateGoal())) mux.HandleFunc("DELETE /api/sites/{id}/goals/{goalID}", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "goals:write", SitePerm: authcore.PermSiteManageGoals, RateLimiter: ctx.ApiLimiter, }, h.handleDeleteGoal())) mux.HandleFunc("GET /api/sites/{id}/funnels", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetFunnels())) mux.HandleFunc("GET /api/sites/{id}/funnels/timeseries", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetFunnelTimeseries())) mux.HandleFunc("POST /api/sites/{id}/funnels", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "goals:write", SitePerm: authcore.PermSiteManageGoals, RateLimiter: ctx.ApiLimiter, }, h.handleCreateFunnel())) mux.HandleFunc("PUT /api/sites/{id}/funnels/{funnelID}", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "goals:write", SitePerm: authcore.PermSiteManageGoals, RateLimiter: ctx.ApiLimiter, }, h.handleUpdateFunnel())) mux.HandleFunc("DELETE /api/sites/{id}/funnels/{funnelID}", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "goals:write", SitePerm: authcore.PermSiteManageGoals, RateLimiter: ctx.ApiLimiter, }, h.handleDeleteFunnel())) mux.HandleFunc("GET /api/sites/{id}/funnels/{funnelID}/stats", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetFunnelStats())) diff --git a/server/oauth/handlers.go b/server/oauth/handlers.go index c281b06f..4fcff41b 100644 --- a/server/oauth/handlers.go +++ b/server/oauth/handlers.go @@ -106,10 +106,16 @@ func (h *handler) authorizeSite(ctx context.Context, g *auth.OAuthGrant, siteID return provider.ErrConsentForbidden } for _, scope := range g.Scopes { - permission := auth.PermSiteView - if scope == provider.ScopeIngestWrite { - permission = auth.PermSiteManageData + if provider.IsActionScope(scope) && (h.ctx.Config == nil || !h.ctx.Config.AIEnabled) { + return provider.ErrConsentForbidden + } + if scope == provider.ScopeBillingWrite { + if h.ctx.AuthorizeOAuthTeam(ctx, g, g.TeamID, scope, auth.CapTeamManageSettings) != nil { + return provider.ErrConsentForbidden + } + continue } + permission := auth.OAuthScopePermission(scope) if err := h.ctx.AuthorizeOAuthSite(ctx, g, siteID, audience, scope, permission); err != nil { return provider.ErrConsentForbidden } @@ -142,6 +148,14 @@ func (h *handler) consent(w http.ResponseWriter, r *http.Request, userID uuid.UU if info.RegistrationTeamID != uuid.Nil() && info.RegistrationTeamID != uuid.UUID(team.ID) { continue } + teamOnly := len(info.Scopes) == 1 && info.Scopes[0] == provider.ScopeBillingWrite + if teamOnly { + candidate := &auth.OAuthGrant{ID: uuid.NewV7(), UserID: userID, TeamID: uuid.UUID(team.ID), Scopes: info.Scopes, Audience: info.Resource} + if h.authorizeSite(r.Context(), candidate, uuid.Nil()) == nil { + info.Teams = append(info.Teams, api.OAuthTeam{ID: uuid.UUID(team.ID), Name: team.Name, Sites: []api.OAuthSite{}}) + } + continue + } sites, err := h.ctx.Store.ListSitesForTenant(r.Context(), team.ID) if err != nil { response(w, http.StatusServiceUnavailable, map[string]string{"error": "unavailable"}) diff --git a/server/oauth_actions_test.go b/server/oauth_actions_test.go new file mode 100644 index 00000000..bdd3a3ff --- /dev/null +++ b/server/oauth_actions_test.go @@ -0,0 +1,250 @@ +package server + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json/v2" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/google/uuid" + "hitkeep/api" + "hitkeep/auth" + "hitkeep/config" + "hitkeep/oauth" + "hitkeep/server/annotations" + serveroauth "hitkeep/server/oauth" + "hitkeep/server/shared" + "hitkeep/server/sites" +) + +func TestOffsiteOAuthActionsConsentPermissionsAndRevocation(t *testing.T) { + store := testServerStore(t) + t.Cleanup(func() { _ = store.Close() }) + user, err := store.CreateUser(t.Context(), "offsite@example.test", "unused") + if err != nil { + t.Fatal(err) + } + site, err := store.CreateSite(t.Context(), user, "offsite.example.test") + if err != nil { + t.Fatal(err) + } + team, err := store.GetSiteTenantID(t.Context(), site.ID) + if err != nil { + t.Fatal(err) + } + conf := &config.Config{PublicURL: "https://example.test", JWTSecret: strings.Repeat("s", 32), AIEnabled: true} + p, err := oauth.New(store, oauth.Options{Issuer: conf.PublicURL, MCPPath: "/mcp", MCPEnabled: true, BrowserSigningKey: []byte(conf.JWTSecret), IsLeader: func() bool { return true }, AgentActionsEnabled: func() bool { return conf.AIEnabled }, Clients: []oauth.ClientMetadata{{ID: "offsite", Name: "Offsite assistant", RedirectURIs: []string{"https://assistant.example/callback"}, AuthMethod: "none", GrantTypes: []string{"authorization_code", "refresh_token"}}}}) + if err != nil { + t.Fatal(err) + } + c := &shared.Context{Store: store, Config: conf, OAuthProvider: p} + mux := http.NewServeMux() + serveroauth.Register(mux, c) + annotations.Register(mux, c) + sites.Register(mux, c) + mux.HandleFunc("POST /api/test/billing", c.Handler(shared.HandlerConfig{RequireAuth: true, OAuthScope: oauth.ScopeBillingWrite, ActiveTeamCap: auth.CapTeamManageSettings}, func(w http.ResponseWriter, r *http.Request) { + id, err := c.ResolveRequestTeamID(r) + if err != nil { + http.Error(w, "denied", http.StatusForbidden) + return + } + _, _ = w.Write([]byte(id.String())) + })) + cookie, err := auth.GenerateToken(conf.JWTSecret, conf.PublicURL, user) + if err != nil { + t.Fatal(err) + } + request := func(method, path, token, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + return w + } + approve := func(scope string, selectedSites []string) (string, string) { + t.Helper() + verifier := strings.Repeat("v", 64) + digest := sha256.Sum256([]byte(verifier)) + form := url.Values{"response_type": {"code"}, "client_id": {"offsite"}, "resource": {p.Resource("api")}, "scope": {scope}, "code_challenge_method": {"S256"}, "code_challenge": {base64.RawURLEncoding.EncodeToString(digest[:])}} + w := request("GET", "/oauth/authorize?"+form.Encode(), "", "") + if w.Code != 303 || w.Header().Get("Location") != p.Issuer()+"/oauth/consent" { + t.Fatalf("authorize: %d %s", w.Code, w.Body.String()) + } + browser := w.Result().Cookies() + consentRequest := func(method, body string) *http.Request { + r := httptest.NewRequest(method, "/api/oauth/consent", strings.NewReader(body)) + for _, item := range browser { + r.AddCookie(item) + } + r.AddCookie(&http.Cookie{Name: auth.CookieName, Value: cookie}) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return r + } + w = httptest.NewRecorder() + mux.ServeHTTP(w, consentRequest("GET", "")) + var choices api.OAuthConsent + if err := json.Unmarshal(w.Body.Bytes(), &choices); err != nil || w.Code != 200 { + t.Fatalf("consent choices: %d %s %v", w.Code, w.Body.String(), err) + } + found := false + for _, choice := range choices.Teams { + if uuid.UUID(choice.ID) == team { + found = true + } + } + if !found { + t.Fatalf("consent team missing: %s", w.Body.String()) + } + decision := url.Values{"decision": {"approve"}, "csrf_token": {choices.CSRFToken}, "team_id": {team.String()}} + for _, id := range selectedSites { + decision.Add("site_ids", id) + } + w = httptest.NewRecorder() + mux.ServeHTTP(w, consentRequest("POST", decision.Encode())) + callback, err := url.Parse(w.Header().Get("Location")) + if err != nil || w.Code != 303 || callback.Query().Get("code") == "" { + t.Fatalf("consent: %d %s %s", w.Code, w.Header().Get("Location"), w.Body.String()) + } + exchange := url.Values{"grant_type": {"authorization_code"}, "client_id": {"offsite"}, "resource": {p.Resource("api")}, "code": {callback.Query().Get("code")}, "code_verifier": {verifier}} + r := httptest.NewRequest("POST", "/oauth/token", strings.NewReader(exchange.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w = httptest.NewRecorder() + mux.ServeHTTP(w, r) + var tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } + if err := json.Unmarshal(w.Body.Bytes(), &tokens); err != nil || w.Code != 200 || tokens.AccessToken == "" { + t.Fatalf("exchange: %d %s %v", w.Code, w.Body.String(), err) + } + return tokens.AccessToken, tokens.RefreshToken + } + write, refresh := approve("sites:read analytics:read annotations:write", []string{site.ID.String()}) + readonly, _ := approve("sites:read analytics:read", []string{site.ID.String()}) + path := "/api/sites/" + site.ID.String() + "/annotations" + body := `{"body":"Offsite note","starts_at":"2026-10-01T12:00:00Z"}` + if w := request("POST", path, readonly, body); w.Code != 403 { + t.Fatalf("readonly wrote: %d %s", w.Code, w.Body.String()) + } + if w := request("POST", path, write, body); w.Code != 201 { + t.Fatalf("write: %d %s", w.Code, w.Body.String()) + } + if w := request("GET", path+"?from=2026-10-01T00:00:00Z&to=2026-10-02T00:00:00Z", write, ""); w.Code != 200 || !strings.Contains(w.Body.String(), "Offsite note") { + t.Fatalf("read: %d %s", w.Code, w.Body.String()) + } + if w := request("GET", "/api/sites", write, ""); w.Code != 200 || !strings.Contains(w.Body.String(), site.Domain) { + t.Fatalf("sites: %d %s", w.Code, w.Body.String()) + } + foreign, err := store.CreateSite(t.Context(), user, "outside-consent.example.test") + if err != nil { + t.Fatal(err) + } + if w := request("POST", "/api/sites/"+foreign.ID.String()+"/annotations", write, body); w.Code != 403 { + t.Fatalf("foreign site: %d", w.Code) + } + if w := request("GET", "/api/sites", write, ""); strings.Contains(w.Body.String(), foreign.Domain) { + t.Fatal("site listing leaked unconsented site") + } + for _, forbidden := range []struct{ method, path string }{{"GET", "/api/sites/" + site.ID.String() + "/hits/export"}, {"DELETE", "/api/sites/" + site.ID.String()}, {"GET", "/api/user/oauth-grants"}} { + if w := request(forbidden.method, forbidden.path, write, ""); w.Code != 401 { + t.Fatalf("forbidden route %s: %d", forbidden.path, w.Code) + } + } + conf.AIEnabled = false + if w := request("POST", path, write, body); w.Code != 404 { + t.Fatalf("AI-off write: %d", w.Code) + } + conf.AIEnabled = true + if err := store.Exec(t.Context(), "UPDATE site_members SET role='viewer' WHERE site_id=? AND user_id=?", site.ID, user); err != nil { + t.Fatal(err) + } + if w := request("POST", path, write, body); w.Code != 403 { + t.Fatalf("demoted wrote: %d", w.Code) + } + if err := store.Exec(t.Context(), "UPDATE site_members SET role='owner' WHERE site_id=? AND user_id=?", site.ID, user); err != nil { + t.Fatal(err) + } + grant, err := p.Authenticate(t.Context(), write, p.Resource("api")) + if err != nil { + t.Fatal(err) + } + if _, err := p.Authenticate(t.Context(), write, p.Resource("mcp")); err == nil { + t.Fatal("API token accepted at MCP resource") + } + if err := store.RevokeOAuthGrant(t.Context(), grant.UserID, grant.ID); err != nil { + t.Fatal(err) + } + if w := request("POST", path, write, body); w.Code != 401 { + t.Fatalf("revoked wrote: %d", w.Code) + } + refreshForm := url.Values{"grant_type": {"refresh_token"}, "client_id": {"offsite"}, "resource": {p.Resource("api")}, "refresh_token": {refresh}} + r := httptest.NewRequest("POST", "/oauth/token", strings.NewReader(refreshForm.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + mux.ServeHTTP(w, r) + if w.Code != 400 { + t.Fatalf("revoked refreshed: %d", w.Code) + } + billing, _ := approve("billing:write", nil) + siteWriter, _ := approve("sites:write", []string{site.ID.String()}) + other, err := store.CreateTenant(t.Context(), user, "Unrelated active team", "") + if err != nil { + t.Fatal(err) + } + if err := store.SetActiveTenantID(t.Context(), user, other.ID); err != nil { + t.Fatal(err) + } + retentionPath := "/api/sites/" + site.ID.String() + "/retention" + if w := request("PUT", retentionPath, siteWriter, `{"days":30}`); w.Code != 200 { + t.Fatalf("consent-team retention after dashboard switch: %d %s", w.Code, w.Body.String()) + } + retained, err := store.GetSiteByID(t.Context(), site.ID) + if err != nil || retained.DataRetentionDays != 30 { + t.Fatalf("retention not persisted: %v %v", retained, err) + } + if w := request("PUT", "/api/sites/"+foreign.ID.String()+"/retention", siteWriter, `{"days":30}`); w.Code != 403 { + t.Fatalf("unconsented retention: %d", w.Code) + } + if err := store.Exec(t.Context(), "UPDATE site_members SET role='viewer' WHERE site_id=? AND user_id=?", site.ID, user); err != nil { + t.Fatal(err) + } + if w := request("PUT", retentionPath, siteWriter, `{"days":60}`); w.Code != 403 { + t.Fatalf("demoted retention: %d", w.Code) + } + if err := store.Exec(t.Context(), "UPDATE site_members SET role='owner' WHERE site_id=? AND user_id=?", site.ID, user); err != nil { + t.Fatal(err) + } + create := httptest.NewRequest("POST", "/api/sites", strings.NewReader(`{"domain":"stale-create.example.test"}`)) + create.AddCookie(&http.Cookie{Name: auth.CookieName, Value: cookie}) + create.Header.Set("X-HitKeep-Agent", "webmcp") + create.Header.Set("X-HitKeep-Agent-Team", team.String()) + created := httptest.NewRecorder() + mux.ServeHTTP(created, create) + if created.Code != 409 { + t.Fatalf("stale team created site: %d %s", created.Code, created.Body.String()) + } + listing := httptest.NewRequest("GET", "/api/sites", nil) + listing.AddCookie(&http.Cookie{Name: auth.CookieName, Value: cookie}) + listing.Header.Set("X-HitKeep-Agent", "webmcp") + listing.Header.Set("X-HitKeep-Agent-Team", team.String()) + listed := httptest.NewRecorder() + mux.ServeHTTP(listed, listing) + if listed.Code != 409 { + t.Fatalf("stale team listed other sites: %d %s", listed.Code, listed.Body.String()) + } + if w := request("POST", "/api/test/billing", billing, ""); w.Code != 200 || w.Body.String() != team.String() { + t.Fatalf("billing binding: %d %s", w.Code, w.Body.String()) + } + if err := store.Exec(t.Context(), "UPDATE tenant_members SET role='member' WHERE tenant_id=? AND user_id=?", team, user); err != nil { + t.Fatal(err) + } + if w := request("POST", "/api/test/billing", billing, ""); w.Code != 403 { + t.Fatalf("demoted billing: %d %s", w.Code, w.Body.String()) + } +} diff --git a/server/oauth_clients.go b/server/oauth_clients.go index 01c0a394..5695c723 100644 --- a/server/oauth_clients.go +++ b/server/oauth_clients.go @@ -24,5 +24,6 @@ func newOAuthProvider(conf *config.Config, store *database.Store, isLeader func( return oauth.New(store, oauth.Options{ Issuer: conf.PublicURL, MCPPath: conf.MCPPath, MCPEnabled: conf.MCPEnabled, BrowserSigningKey: []byte(conf.JWTSecret), Clients: clients, IsLeader: isLeader, + AgentActionsEnabled: func() bool { return conf.AIEnabled }, }) } diff --git a/server/opportunities/handlers.go b/server/opportunities/handlers.go index 759ec378..8fa4ba09 100644 --- a/server/opportunities/handlers.go +++ b/server/opportunities/handlers.go @@ -26,18 +26,22 @@ const maxGenerateRangeDays = 366 func Register(mux *http.ServeMux, ctx *shared.Context) { h := &handler{ctx: ctx} mux.HandleFunc("GET /api/sites/{id}/opportunities", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleList())) mux.HandleFunc("GET /api/sites/{id}/opportunities/digest-preview", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleDigestPreview())) mux.HandleFunc("POST /api/sites/{id}/opportunities/generate", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "opportunities:write", SitePerm: authcore.PermSiteManageData, RateLimiter: ctx.ApiLimiter, }, h.handleGenerate())) mux.HandleFunc("PATCH /api/sites/{id}/opportunities/{opportunityID}", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "opportunities:write", SitePerm: authcore.PermSiteManageData, RateLimiter: ctx.ApiLimiter, }, h.handleUpdateStatus())) diff --git a/server/server.go b/server/server.go index 827b968d..c1d069b2 100644 --- a/server/server.go +++ b/server/server.go @@ -257,6 +257,7 @@ func New(conf *config.Config, publicFS fs.FS, store *database.Store, tenantStore handler = s.stripPublicBasePath(rootHandler) } handler = s.customTrackingHostMiddleware(publicFS, rootHandler, handler) + handler = s.agentRequestMiddleware(handler) handler = s.requestLoggingMiddleware(handler) s.httpServer = &http.Server{ @@ -403,6 +404,7 @@ func (s *Server) setupRoutes(mux *http.ServeMux, publicFS fs.FS) { ctx := s.ctx system.Register(mux, ctx) s.registerDiscoveryRoutes(mux) + s.registerA2ARoutes(mux) mux.HandleFunc("GET /internal/caddy/on-demand-tls/{token}", s.handleCaddyOnDemandTLSAsk()) ingest.Register(mux, ctx) serverauth.Register(mux, ctx) diff --git a/server/shared/authn.go b/server/shared/authn.go index 9aeac0b7..b70b74b8 100644 --- a/server/shared/authn.go +++ b/server/shared/authn.go @@ -127,6 +127,16 @@ func (c *Context) RequireAuth(allowAPIKey bool, next http.HandlerFunc) http.Hand ctx = context.WithValue(ctx, AuthSessionKey, *sessionCtx) } ctx = WithLoggerAttrs(ctx, "user_id", userID.String()) + if sessionCtx != nil && len(r.Header.Values("X-Hitkeep-Agent")) > 0 { + teamID, err := c.Store.GetActiveTenantID(ctx, userID) + if err != nil { + http.Error(w, "Unable to resolve active team", http.StatusBadRequest) + return + } + if !CheckAgentTeam(w, r, teamID) { + return + } + } next(w, r.WithContext(ctx)) } } diff --git a/server/shared/authz.go b/server/shared/authz.go index 5fe3a469..bc92b862 100644 --- a/server/shared/authz.go +++ b/server/shared/authz.go @@ -24,6 +24,13 @@ type PermissionContext struct { func (c *Context) RequirePermission(perm auth.Permission) func(http.HandlerFunc) http.HandlerFunc { return func(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + if isSitePermission(perm) && !c.checkAgentSiteTeam(w, r) { + return + } + if grant, _ := r.Context().Value(OAuthGrantKey).(*auth.OAuthGrant); grant != nil { + c.requireOAuthSitePermission(perm, next, w, r, grant) + return + } userID := GetUserIDFromContext(r) apiClientAuth, _ := r.Context().Value(APIClientAuthKey).(*database.APIClientAuth) if userID == uuid.Nil && apiClientAuth == nil { @@ -116,6 +123,9 @@ func (c *Context) RequireTeamCapability(capability auth.Capability) func(http.Ha if !ok { return } + if !CheckAgentTeam(w, r, teamID) { + return + } if !c.userHasTeamCapability(r.Context(), teamID, userID, capability) { http.Error(w, "Access denied", http.StatusForbidden) @@ -127,10 +137,19 @@ func (c *Context) RequireTeamCapability(capability auth.Capability) func(http.Ha } } -// RequireActiveTeamCapability checks a human user's current team on routes without a team ID path parameter. +// RequireActiveTeamCapability checks the session's active team or the OAuth consent team. func (c *Context) RequireActiveTeamCapability(capability auth.Capability) func(http.HandlerFunc) http.HandlerFunc { return func(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + if grant, _ := r.Context().Value(OAuthGrantKey).(*auth.OAuthGrant); grant != nil { + scope, _ := r.Context().Value(oauthActionScopeKey).(string) + if c.AuthorizeOAuthTeam(r.Context(), grant, grant.TeamID, scope, capability) != nil { + http.Error(w, "Access denied", http.StatusForbidden) + return + } + next(w, r) + return + } userID := GetUserIDFromContext(r) if userID == uuid.Nil || r.Context().Value(APIClientAuthKey) != nil || r.Context().Value(OAuthGrantKey) != nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) @@ -145,6 +164,9 @@ func (c *Context) RequireActiveTeamCapability(capability auth.Capability) func(h http.Error(w, "Unable to resolve active team", http.StatusBadRequest) return } + if !CheckAgentTeam(w, r, teamID) { + return + } if !c.userHasTeamCapability(r.Context(), teamID, userID, capability) { http.Error(w, "Access denied", http.StatusForbidden) return @@ -190,6 +212,13 @@ func (c *Context) sitePermissionContext( func (c *Context) RequireSiteOrInstancePermission(sitePerm, instancePerm auth.Permission) func(http.HandlerFunc) http.HandlerFunc { return func(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + if sitePerm != "" && !c.checkAgentSiteTeam(w, r) { + return + } + if grant, _ := r.Context().Value(OAuthGrantKey).(*auth.OAuthGrant); grant != nil { + c.requireOAuthSitePermission(sitePerm, next, w, r, grant) + return + } userID := GetUserIDFromContext(r) apiClientAuth, _ := r.Context().Value(APIClientAuthKey).(*database.APIClientAuth) if userID == uuid.Nil && apiClientAuth == nil { @@ -242,6 +271,36 @@ func (c *Context) RequireSiteOrInstancePermission(sitePerm, instancePerm auth.Pe } } +// CheckAgentTeam binds browser-agent approval to the actual resource team; it grants no authority. +func CheckAgentTeam(w http.ResponseWriter, r *http.Request, teamID uuid.UUID) bool { + if len(r.Header.Values("X-Hitkeep-Agent")) == 0 { + return true + } + expected, err := uuid.Parse(r.Header.Get("X-Hitkeep-Agent-Team")) + if len(r.Header.Values("X-Hitkeep-Agent-Team")) != 1 || err != nil || expected != teamID { + http.Error(w, "Team changed; refresh agent context", http.StatusConflict) + return false + } + return true +} + +func (c *Context) checkAgentSiteTeam(w http.ResponseWriter, r *http.Request) bool { + if len(r.Header.Values("X-Hitkeep-Agent")) == 0 { + return true + } + id, err := siteIDFromRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return false + } + team, err := c.Store.GetSiteTenantID(r.Context(), id) + if err != nil { + http.Error(w, "Access denied", http.StatusForbidden) + return false + } + return CheckAgentTeam(w, r, team) +} + func isSitePermission(perm auth.Permission) bool { return strings.HasPrefix(string(perm), "site.") } diff --git a/server/shared/context.go b/server/shared/context.go index 0181ec9d..a4c1cda9 100644 --- a/server/shared/context.go +++ b/server/shared/context.go @@ -41,6 +41,7 @@ type HandlerConfig struct { AllowAPIKey bool APIClientOnly bool HumanOnly bool + OAuthScope string RateLimiter *IPRateLimiter } @@ -94,6 +95,11 @@ type Context struct { MailTestTracker *database.MailTestTracker } +// A2AAvailable is shared by protocol execution and runtime discovery. +func (c *Context) A2AAvailable() bool { + return c != nil && c.Config != nil && c.Config.AIEnabled && c.Store != nil && c.TenantStores != nil && c.OAuthProvider != nil && c.OAuthProvider.Ready() && (c.Cluster == nil || c.Cluster.IsLeader()) +} + // AnalyticsStore resolves the tenant-specific store that holds analytics data for the given site. // Analytics must never fall back to the control store: followers are expected to // proxy stateful requests to the leader and an unavailable tenant data plane must @@ -180,19 +186,27 @@ func (c *Context) applyAccessChecks(config HandlerConfig, handler http.HandlerFu func (c *Context) applyAuthentication(config HandlerConfig, handler http.HandlerFunc) http.HandlerFunc { if config.HumanOnly { - return c.RequireHumanAuth(handler) + ordinary := c.RequireHumanAuth(handler) + if config.OAuthScope != "" { + return c.RequireActionOAuth(config.OAuthScope, handler, ordinary) + } + return ordinary } if config.APIClientOnly { return c.RequireAPIClientAuth(handler) } if config.requiresUserAuth() { - return c.RequireAuth(config.allowsAPIKey(), handler) + ordinary := c.RequireAuth(config.allowsAPIKey(), handler) + if config.OAuthScope != "" { + return c.RequireActionOAuth(config.OAuthScope, handler, ordinary) + } + return ordinary } return handler } func (config HandlerConfig) requiresUserAuth() bool { - return config.RequireAuth || config.InstancePerm != "" || config.SitePerm != "" || config.TeamCap != "" || config.ActiveTeamCap != "" + return config.RequireAuth || config.InstancePerm != "" || config.SitePerm != "" || config.TeamCap != "" || config.ActiveTeamCap != "" || config.OAuthScope != "" } func (config HandlerConfig) allowsAPIKey() bool { diff --git a/server/shared/oauth.go b/server/shared/oauth.go index 76b5df88..73fc1a02 100644 --- a/server/shared/oauth.go +++ b/server/shared/oauth.go @@ -15,9 +15,72 @@ import ( ) const OAuthGrantKey contextKey = "oauth_grant" +const oauthActionScopeKey contextKey = "oauth_action_scope" var ErrOAuthAccessDenied = errors.New("oauth access denied") +// RequireActionOAuth opts a route into delegated actions without changing its session/API-client access. +func (c *Context) RequireActionOAuth(scope string, next, ordinary http.HandlerFunc) http.HandlerFunc { + delegated := c.RequireIntegrationAuth(scope, func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), oauthActionScopeKey, scope) + next(w, r.WithContext(ctx)) + }) + return func(w http.ResponseWriter, r *http.Request) { + if !oauth.IsOAuthToken(extractAPIClientToken(r)) { + ordinary(w, r) + return + } + if c.Config == nil || !c.Config.AIEnabled { + http.NotFound(w, r) + return + } + delegated(w, r) + } +} + +// ResolveRequestTeamID keeps delegated requests on the team approved in consent. +func (c *Context) ResolveRequestTeamID(r *http.Request) (legacyuuid.UUID, error) { + if grant, _ := r.Context().Value(OAuthGrantKey).(*auth.OAuthGrant); grant != nil { + if grant.TeamID == uuid.Nil() || grant.UserID != uuid.UUID(GetUserIDFromContext(r)) { + return legacyuuid.Nil, ErrOAuthAccessDenied + } + return legacyuuid.UUID(grant.TeamID), nil + } + return c.Store.GetActiveTenantID(r.Context(), GetUserIDFromContext(r)) +} + +// AuthorizeOAuthTeam permits only the explicit billing delegation, with current team authority. +func (c *Context) AuthorizeOAuthTeam(ctx context.Context, grant *auth.OAuthGrant, teamID uuid.UUID, scope string, capability auth.Capability) error { + if c.Store == nil || c.OAuthProvider == nil || grant == nil || grant.ID == uuid.Nil() || grant.UserID == uuid.Nil() || teamID == uuid.Nil() || grant.TeamID != teamID || grant.Audience != c.OAuthProvider.Resource("api") || scope != oauth.ScopeBillingWrite || !slices.Contains(grant.Scopes, scope) || capability != auth.CapTeamManageSettings { + return ErrOAuthAccessDenied + } + role, err := c.Store.GetTenantRole(ctx, legacyuuid.UUID(teamID), legacyuuid.UUID(grant.UserID)) + if err != nil || !auth.TeamRoleHasCapability(role, capability) { + return ErrOAuthAccessDenied + } + return nil +} + +func (c *Context) requireOAuthSitePermission(perm auth.Permission, next http.HandlerFunc, w http.ResponseWriter, r *http.Request, grant *auth.OAuthGrant) { + siteID, err := siteIDFromRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + scope, _ := r.Context().Value(oauthActionScopeKey).(string) + if c.OAuthProvider == nil { + http.Error(w, "Access denied", http.StatusForbidden) + return + } + role, err := c.authorizeOAuthSite(r.Context(), grant, uuid.UUID(siteID), c.OAuthProvider.Resource("api"), scope, perm) + if err != nil { + http.Error(w, "Access denied", http.StatusForbidden) + return + } + ctx := context.WithValue(r.Context(), PermissionKey, PermissionContext{UserID: legacyuuid.UUID(grant.UserID), InstanceRole: auth.InstanceUser, SiteRole: role}) + next(w, r.WithContext(ctx)) +} + // RequireIntegrationAuth accepts integration credentials without dashboard cookies. func (c *Context) RequireIntegrationAuth(requiredScope string, next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -75,17 +138,22 @@ func (c *Context) writeOAuthAuthenticationError(w http.ResponseWriter) { // AuthorizeOAuthSite revalidates current consent-team membership and site roles. func (c *Context) AuthorizeOAuthSite(ctx context.Context, grant *auth.OAuthGrant, siteID uuid.UUID, expectedAudience, requiredScope string, permission auth.Permission) error { + _, err := c.authorizeOAuthSite(ctx, grant, siteID, expectedAudience, requiredScope, permission) + return err +} + +func (c *Context) authorizeOAuthSite(ctx context.Context, grant *auth.OAuthGrant, siteID uuid.UUID, expectedAudience, requiredScope string, permission auth.Permission) (auth.SiteRole, error) { if c.Store == nil || grant == nil || expectedAudience == "" || grant.Audience != expectedAudience || !slices.Contains(grant.Scopes, requiredScope) || !slices.Contains(grant.SiteIDs, siteID) { - return ErrOAuthAccessDenied + return "", ErrOAuthAccessDenied } role, err := c.Store.GetSiteRoleForTenant(ctx, grant.UserID, siteID, grant.TeamID) if err != nil { - return ErrOAuthAccessDenied + return "", ErrOAuthAccessDenied } resource := auth.Resource{Kind: auth.ResourceSite, ID: siteID, TeamID: grant.TeamID} principal := auth.Principal{Kind: auth.PrincipalOAuth, SiteID: siteID, TeamID: grant.TeamID, SiteRole: role} if !auth.AuthorizeOAuth(grant, principal, auth.Capability(permission), resource, expectedAudience, requiredScope) { - return ErrOAuthAccessDenied + return "", ErrOAuthAccessDenied } - return nil + return role, nil } diff --git a/server/shared/responses.go b/server/shared/responses.go index 68c5c9cf..01435182 100644 --- a/server/shared/responses.go +++ b/server/shared/responses.go @@ -65,6 +65,7 @@ func (c *Context) SystemStatusResponse(ctx context.Context) (api.SystemStatus, e mailStatus = "available" } return api.SystemStatus{ + AIEnabled: c.Config.AIEnabled, NeedsSetup: userCount == 0 && !c.Config.CloudHosted, Version: c.Config.Version, Cloud: c.CloudStatus(), diff --git a/server/shared/team_capability_test.go b/server/shared/team_capability_test.go index 7456cf16..69d4e675 100644 --- a/server/shared/team_capability_test.go +++ b/server/shared/team_capability_test.go @@ -26,6 +26,7 @@ func TestHandlerConfigAuthHelpers(t *testing.T) { {"human-only permission", HandlerConfig{SitePerm: auth.PermSiteManageWebhooks, HumanOnly: true}, true, false}, {"team capability", HandlerConfig{TeamCap: auth.CapTeamManageSettings}, true, false}, {"active team capability", HandlerConfig{ActiveTeamCap: auth.CapTeamManageSettings}, true, false}, + {"oauth action", HandlerConfig{OAuthScope: "annotations:write"}, true, false}, {"api client only", HandlerConfig{APIClientOnly: true}, false, false}, {"open route", HandlerConfig{}, false, false}, } @@ -250,3 +251,98 @@ func newSharedTestStore(t *testing.T) *database.Store { t.Helper() return testdb.Shared(t) } + +func TestRequireActiveTeamCapabilityAgentContext(t *testing.T) { + store := newSharedTestStore(t) + user, err := store.CreateUser(t.Context(), "agent-team-owner@example.test", "hash") + if err != nil { + t.Fatal(err) + } + team, err := store.GetActiveTenantID(t.Context(), user) + if err != nil { + t.Fatal(err) + } + app := &Context{Store: store} + next := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) } + for _, tc := range []struct { + name, expected string + marked bool + want int + }{ + {"unmarked stale header", uuid.New().String(), false, 204}, + {"matching", team.String(), true, 204}, + {"changed team", uuid.New().String(), true, 409}, + {"missing team", "", true, 409}, + {"malformed", "invalid", true, 409}, + } { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest("POST", "/api/cloud/billing/state", nil) + r = r.WithContext(context.WithValue(r.Context(), UserIDKey, user)) + r.Header.Set("X-HitKeep-Agent-Team", tc.expected) + if tc.marked { + r.Header.Set("X-HitKeep-Agent", "webmcp") + } + w := httptest.NewRecorder() + app.RequireActiveTeamCapability(auth.CapTeamManageSettings)(next).ServeHTTP(w, r) + if w.Code != tc.want { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + }) + } +} + +func TestAgentExplicitResourcesStayOnApprovedTeam(t *testing.T) { + store := newSharedTestStore(t) + user, err := store.CreateUser(t.Context(), "agent-explicit-owner@example.test", "hash") + if err != nil { + t.Fatal(err) + } + approved, err := store.GetActiveTenantID(t.Context(), user) + if err != nil { + t.Fatal(err) + } + other, err := store.CreateTenant(t.Context(), user, "Other administered team", "") + if err != nil { + t.Fatal(err) + } + if err := store.SetActiveTenantID(t.Context(), user, other.ID); err != nil { + t.Fatal(err) + } + site, err := store.CreateSite(t.Context(), user, "other-approved-team.example.test") + if err != nil { + t.Fatal(err) + } + if err := store.UpdateInstanceRole(t.Context(), user, auth.InstanceOwner, user); err != nil { + t.Fatal(err) + } + app := &Context{Store: store} + next := func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) } + for _, expected := range []uuid.UUID{approved, other.ID} { + want := 409 + if expected == other.ID { + want = 204 + } + for _, strict := range []bool{false, true} { + r := sitePermissionRequest(user, site.ID) + r.Header.Set("X-HitKeep-Agent", "webmcp") + r.Header.Set("X-HitKeep-Agent-Team", expected.String()) + w := httptest.NewRecorder() + if strict { + app.RequireSiteOrInstancePermission(auth.PermSiteManageData, auth.PermInstanceManageImports)(next)(w, r) + } else { + app.RequirePermission(auth.PermSiteView)(next)(w, r) + } + if w.Code != want { + t.Fatalf("site strict=%v: got %d want %d", strict, w.Code, want) + } + } + r := teamRequest(user, other.ID.String()) + r.Header.Set("X-HitKeep-Agent", "webmcp") + r.Header.Set("X-HitKeep-Agent-Team", expected.String()) + w := httptest.NewRecorder() + app.RequireTeamCapability(auth.CapTeamArchive)(next)(w, r) + if w.Code != want { + t.Fatalf("team: got %d want %d", w.Code, want) + } + } +} diff --git a/server/sites/handlers.go b/server/sites/handlers.go index a1239dda..729438fa 100644 --- a/server/sites/handlers.go +++ b/server/sites/handlers.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" "time" + stdUUID "uuid" "github.com/google/uuid" @@ -31,6 +32,7 @@ func Register(mux *http.ServeMux, ctx *shared.Context) { requireExclusionAccess := ctx.RequireSiteOrInstancePermission(authcore.PermSiteManageData, authcore.PermInstanceManageSiteExclusions) mux.HandleFunc("GET /api/sites", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "sites:read", RequireAuth: true, AllowAPIKey: true, RateLimiter: ctx.ApiLimiter, @@ -49,6 +51,7 @@ func Register(mux *http.ServeMux, ctx *shared.Context) { RateLimiter: ctx.ApiLimiter, }, h.handleDeleteSite())) mux.HandleFunc("GET /api/sites/{id}/stats", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetSiteStats())) @@ -57,14 +60,17 @@ func Register(mux *http.ServeMux, ctx *shared.Context) { RateLimiter: ctx.ApiLimiter, }, h.handleResetSiteStats())) mux.HandleFunc("GET /api/sites/{id}/setup-state", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetSiteSetupState())) mux.HandleFunc("GET /api/sites/{id}/tracking/status", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetSiteTrackingStatus())) mux.HandleFunc("GET /api/sites/{id}/tracking-domain-options", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetSiteTrackingDomainOptions())) @@ -81,38 +87,47 @@ func Register(mux *http.ServeMux, ctx *shared.Context) { RateLimiter: ctx.ApiLimiter, }, h.handleGetSiteRealtime())) mux.HandleFunc("GET /api/sites/{id}/ecommerce", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetSiteEcommerceSummary())) mux.HandleFunc("GET /api/sites/{id}/ecommerce/timeseries", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetSiteEcommerceTimeseries())) mux.HandleFunc("GET /api/sites/{id}/ecommerce/products", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetSiteEcommerceProducts())) mux.HandleFunc("GET /api/sites/{id}/ecommerce/sources", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetSiteEcommerceSources())) mux.HandleFunc("GET /api/sites/{id}/ai-activity", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetSiteAIActivity())) mux.HandleFunc("GET /api/sites/{id}/web-vitals/summary", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetSiteWebVitalsSummary())) mux.HandleFunc("GET /api/sites/{id}/web-vitals/timeseries", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetSiteWebVitalsTimeseries())) mux.HandleFunc("GET /api/sites/{id}/web-vitals/pages", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetSiteWebVitalsPages())) mux.HandleFunc("GET /api/sites/{id}/web-vitals/breakdown", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "analytics:read", SitePerm: authcore.PermSiteView, RateLimiter: ctx.ApiLimiter, }, h.handleGetSiteWebVitalsBreakdown())) @@ -120,10 +135,12 @@ func Register(mux *http.ServeMux, ctx *shared.Context) { RateLimiter: ctx.ApiLimiter, }, h.handleGetFavicon())) mux.HandleFunc("PUT /api/sites/{id}/domain", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "sites:write", SitePerm: authcore.PermSiteManageData, RateLimiter: ctx.ApiLimiter, }, h.handleRenameSiteDomain())) mux.HandleFunc("PUT /api/sites/{id}/retention", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "sites:write", SitePerm: authcore.PermSiteManageData, RateLimiter: ctx.ApiLimiter, }, h.handleUpdateSiteRetention())) @@ -132,16 +149,19 @@ func Register(mux *http.ServeMux, ctx *shared.Context) { RateLimiter: ctx.ApiLimiter, }, h.handleTransferSiteTeam())) mux.HandleFunc("GET /api/sites/{id}/exclusions", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "sites:write", RequireAuth: true, AllowAPIKey: true, RateLimiter: ctx.ApiLimiter, }, requireExclusionAccess(h.handleListSiteExclusions()))) mux.HandleFunc("POST /api/sites/{id}/exclusions", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "sites:write", RequireAuth: true, AllowAPIKey: true, RateLimiter: ctx.ApiLimiter, }, requireExclusionAccess(h.handleCreateSiteExclusion()))) mux.HandleFunc("DELETE /api/sites/{id}/exclusions/{ruleID}", ctx.Handler(shared.HandlerConfig{ + OAuthScope: "sites:write", RequireAuth: true, AllowAPIKey: true, RateLimiter: ctx.ApiLimiter, @@ -182,6 +202,7 @@ func (h *handler) handleGetSites() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { userID := shared.GetUserIDFromContext(r) apiClientAuth, _ := r.Context().Value(shared.APIClientAuthKey).(*database.APIClientAuth) + grant, _ := r.Context().Value(shared.OAuthGrantKey).(*authcore.OAuthGrant) if userID == uuid.Nil && apiClientAuth == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return @@ -197,6 +218,8 @@ func (h *handler) handleGetSites() http.HandlerFunc { err error ) switch { + case grant != nil: + sites, err = h.ctx.Store.ListSitesForTenant(r.Context(), uuid.UUID(grant.TeamID)) case userID != uuid.Nil: sites, err = h.ctx.Store.GetSites(r.Context(), userID) case apiClientAuth != nil && apiClientAuth.TenantID != uuid.Nil: @@ -211,6 +234,15 @@ func (h *handler) handleGetSites() http.HandlerFunc { return } + if grant != nil { + filtered := make([]api.Site, 0, len(sites)) + for _, site := range sites { + if h.ctx.AuthorizeOAuthSite(r.Context(), grant, stdUUID.UUID(site.ID), h.ctx.OAuthProvider.Resource("api"), "sites:read", authcore.PermSiteView) == nil { + filtered = append(filtered, site) + } + } + sites = filtered + } if apiClientAuth != nil { filtered := make([]api.Site, 0, len(sites)) for _, site := range sites { @@ -267,6 +299,9 @@ func (h *handler) handleCreateSite() http.HandlerFunc { http.Error(w, "Internal server error", http.StatusInternalServerError) return } + if !shared.CheckAgentTeam(w, r, teamID) { + return + } site, err := h.ctx.Store.CreateSiteWithQuota(r.Context(), userID, teamID, domain, h.ctx.Limits().TeamSiteLimit(r.Context(), teamID)) if err != nil { if writeCreateSiteError(w, err) { @@ -580,8 +615,14 @@ func (h *handler) handleUpdateSiteRetention() http.HandlerFunc { return } - site, err := h.ctx.Store.GetSite(r.Context(), siteID, userID) - if err != nil || site == nil { + var site *api.Site + if grant, _ := r.Context().Value(shared.OAuthGrantKey).(*authcore.OAuthGrant); grant != nil { + // The shared guard already checked the consent team and current site authority. + site, err = h.ctx.Store.GetSiteByID(r.Context(), siteID) + } else { + site, err = h.ctx.Store.GetSite(r.Context(), siteID, userID) + } + if err != nil || site == nil || site.UserID != userID { http.Error(w, "Site not found", http.StatusNotFound) return } diff --git a/server/system/api_docs_actions.go b/server/system/api_docs_actions.go new file mode 100644 index 00000000..b4fb8b11 --- /dev/null +++ b/server/system/api_docs_actions.go @@ -0,0 +1,54 @@ +package system + +import "strings" + +// addOAuthActionSecurity keeps ordinary REST schemas and their existing authentication alternatives. +func addOAuthActionSecurity(paths map[string]any) { + scopes := map[string]string{ + "GET /api/sites": "sites:read", + "GET /api/sites/{id}/stats": "analytics:read", + "GET /api/sites/{id}/setup-state": "analytics:read", + "GET /api/sites/{id}/tracking/status": "analytics:read", + "GET /api/sites/{id}/tracking-domain-options": "analytics:read", + "GET /api/sites/{id}/ecommerce": "analytics:read", + "GET /api/sites/{id}/ecommerce/timeseries": "analytics:read", + "GET /api/sites/{id}/ecommerce/products": "analytics:read", + "GET /api/sites/{id}/ecommerce/sources": "analytics:read", + "GET /api/sites/{id}/ai-activity": "analytics:read", + "GET /api/sites/{id}/web-vitals/summary": "analytics:read", + "GET /api/sites/{id}/web-vitals/timeseries": "analytics:read", + "GET /api/sites/{id}/web-vitals/pages": "analytics:read", + "GET /api/sites/{id}/web-vitals/breakdown": "analytics:read", + "PUT /api/sites/{id}/domain": "sites:write", + "PUT /api/sites/{id}/retention": "sites:write", + "GET /api/sites/{id}/exclusions": "sites:write", + "POST /api/sites/{id}/exclusions": "sites:write", + "DELETE /api/sites/{id}/exclusions/{ruleID}": "sites:write", + "GET /api/sites/{id}/goals": "analytics:read", + "GET /api/sites/{id}/goals/timeseries": "analytics:read", + "POST /api/sites/{id}/goals": "goals:write", + "PUT /api/sites/{id}/goals/{goalID}": "goals:write", + "DELETE /api/sites/{id}/goals/{goalID}": "goals:write", + "GET /api/sites/{id}/funnels": "analytics:read", + "GET /api/sites/{id}/funnels/timeseries": "analytics:read", + "POST /api/sites/{id}/funnels": "goals:write", + "PUT /api/sites/{id}/funnels/{funnelID}": "goals:write", + "DELETE /api/sites/{id}/funnels/{funnelID}": "goals:write", + "GET /api/sites/{id}/funnels/{funnelID}/stats": "analytics:read", + "GET /api/sites/{id}/annotations": "analytics:read", + "POST /api/sites/{id}/annotations": "annotations:write", + "PUT /api/sites/{id}/annotations/{annotationID}": "annotations:write", + "DELETE /api/sites/{id}/annotations/{annotationID}": "annotations:write", + "GET /api/sites/{id}/opportunities": "analytics:read", + "GET /api/sites/{id}/opportunities/digest-preview": "analytics:read", + "POST /api/sites/{id}/opportunities/generate": "opportunities:write", + "PATCH /api/sites/{id}/opportunities/{opportunityID}": "opportunities:write", + } + for route, scope := range scopes { + method, path, _ := strings.Cut(route, " ") + operation := paths[path].(map[string]any)[strings.ToLower(method)].(map[string]any) + operation["security"] = append(operation["security"].([]any), map[string]any{"oauth2": []string{scope}}) + operation["x-hitkeep-oauth-scope"] = scope + operation["description"] = operation["description"].(string) + " Delegated OAuth requires AIEnabled, the API resource, this operation’s exact scope, explicit site consent, and current site/team authority. Existing session and API-client authorization remains unchanged." + } +} diff --git a/server/system/api_docs_actions_test.go b/server/system/api_docs_actions_test.go new file mode 100644 index 00000000..ef8cf5c9 --- /dev/null +++ b/server/system/api_docs_actions_test.go @@ -0,0 +1,85 @@ +package system + +import ( + "go/ast" + "go/parser" + "go/token" + "strconv" + "strings" + "testing" + + "hitkeep/server/shared" +) + +func TestOpenAPIDelegatedActionScopesMatchRegisteredRoutes(t *testing.T) { + paths := OpenAPISpecV1("https://example.test")["paths"].(map[string]any) + seen := make(map[string]bool) + for _, source := range []string{"../sites/handlers.go", "../goals/handlers.go", "../annotations/handlers.go", "../opportunities/handlers.go"} { + file, err := parser.ParseFile(token.NewFileSet(), source, nil, 0) + if err != nil { + t.Fatal(err) + } + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok || len(call.Args) != 2 { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "HandleFunc" { + return true + } + literal, ok := call.Args[0].(*ast.BasicLit) + if !ok { + return true + } + route, err := strconv.Unquote(literal.Value) + if err != nil { + t.Fatal(err) + } + ast.Inspect(call.Args[1], func(node ast.Node) bool { + field, ok := node.(*ast.KeyValueExpr) + if !ok { + return true + } + key, ok := field.Key.(*ast.Ident) + if !ok || key.Name != "OAuthScope" { + return true + } + value, ok := field.Value.(*ast.BasicLit) + if !ok { + t.Fatal("route scope must be explicit") + } + scope, err := strconv.Unquote(value.Value) + if err != nil { + t.Fatal(err) + } + method, path, _ := strings.Cut(route, " ") + operation := paths[path].(map[string]any)[strings.ToLower(method)].(map[string]any) + if operation["x-hitkeep-oauth-scope"] != scope { + t.Fatalf("%s scope=%v want %s", route, operation["x-hitkeep-oauth-scope"], scope) + } + seen[route] = true + return false + }) + return false + }) + } + for path, item := range paths { + for method, value := range item.(map[string]any) { + operation, ok := value.(map[string]any) + if ok && operation["x-hitkeep-oauth-scope"] != nil && !seen[strings.ToUpper(method)+" "+path] { + t.Fatalf("unregistered delegated operation %s %s", method, path) + } + } + } + for _, route := range []string{"GET /api/sites/{id}/hits/export", "DELETE /api/sites/{id}", "POST /api/user/api-clients", "GET /api/user/oauth-grants"} { + method, path, _ := strings.Cut(route, " ") + if paths[path].(map[string]any)[strings.ToLower(method)].(map[string]any)["x-hitkeep-oauth-scope"] != nil { + t.Fatalf("unsafe delegated operation %s", route) + } + } + paths = OpenAPISpecV1ForRuntime("https://example.test", &shared.Context{})["paths"].(map[string]any) + if paths["/api/sites/{id}/annotations"].(map[string]any)["post"].(map[string]any)["x-hitkeep-oauth-scope"] != nil { + t.Fatal("unavailable OAuth actions advertised") + } +} diff --git a/server/system/api_docs_commerce.go b/server/system/api_docs_commerce.go new file mode 100644 index 00000000..f3b34fe8 --- /dev/null +++ b/server/system/api_docs_commerce.go @@ -0,0 +1,59 @@ +package system + +func openAPIV1CommercePaths() map[string]any { + paths := map[string]any{} + response := map[string]any{"type": "object", "required": []string{"id", "status", "currency", "line_items", "totals", "messages", "links"}, "properties": map[string]any{ + "id": map[string]any{"type": "string"}, "status": map[string]any{"type": "string", "enum": []string{"requires_escalation", "complete_in_progress", "completed", "canceled", "incomplete"}}, "currency": map[string]any{"type": "string"}, "continue_url": map[string]any{"type": "string", "format": "uri"}, "line_items": map[string]any{"type": "array", "items": map[string]any{"type": "object"}}, "totals": map[string]any{"type": "array", "items": map[string]any{"type": "object"}}, "messages": map[string]any{"type": "array", "items": map[string]any{"type": "object"}}, "links": map[string]any{"type": "array", "items": map[string]any{"type": "object"}}, "order": map[string]any{"type": "object"}, + }} + for _, protocol := range []string{"acp", "ucp"} { + base := "/api/cloud/commerce/" + protocol + "/checkout_sessions" + update := "post" + version := "2026-04-17" + if protocol == "ucp" { + base = "/api/cloud/commerce/ucp/checkout-sessions" + update = "put" + version = "2026-08-25" + } + for _, route := range []struct{ suffix, method, action string }{{"", "post", "Create"}, {"/{id}", "get", "Retrieve"}, {"/{id}", update, "Update"}, {"/{id}/complete", "post", "Complete"}, {"/{id}/cancel", "post", "Cancel"}} { + parameters := []any{} + if route.suffix != "" { + parameters = append(parameters, map[string]any{"name": "id", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}) + } + if protocol == "acp" { + parameters = append(parameters, map[string]any{"name": "API-Version", "in": "header", "required": true, "schema": map[string]any{"type": "string", "const": version}}) + } + if protocol == "ucp" { + parameters = append(parameters, map[string]any{"name": "UCP-Agent", "in": "header", "required": true, "schema": map[string]any{"type": "string", "description": "profile=\"https://platform.example/.well-known/ucp\"; public HTTPS, exact checkout version, bounded fetch, no redirects"}}, map[string]any{"name": "Request-Id", "in": "header", "required": true, "schema": map[string]any{"type": "string", "minLength": 1, "maxLength": 255}}) + } + if route.method != "get" { + parameters = append(parameters, map[string]any{"name": "Idempotency-Key", "in": "header", "required": true, "schema": map[string]any{"type": "string", "minLength": 1, "maxLength": 255}}) + } + var body any + if route.method != "get" { + id := map[string]any{"type": "string", "enum": []string{"pro:monthly", "pro:annual", "business:monthly", "business:annual"}} + item := map[string]any{"type": "object", "required": []string{"id"}, "properties": map[string]any{"id": id}, "additionalProperties": false} + if protocol == "ucp" { + item = map[string]any{"type": "object", "required": []string{"item", "quantity"}, "properties": map[string]any{"item": item, "quantity": map[string]any{"type": "integer", "const": 1}}} + } + schema := map[string]any{"type": "object", "properties": map[string]any{"line_items": map[string]any{"type": "array", "minItems": 1, "maxItems": 1, "items": item}}, "description": "One line_items entry: ACP 2026-04-17 uses a direct id and implicitly purchases one subscription; UCP uses item.id and quantity 1. Amounts and regional currency are server-owned. Completed sessions are immutable; changing a plan requires canceling the pending checkout. No payment credential is consumed by this human-approved adapter."} + if route.action == "Create" { + schema["required"] = []string{"line_items"} + } + body = jsonBody(schema) + } + success := "200" + if route.action == "Create" { + success = "201" + } + operation := cloudOp(route.action+" "+protocol+" checkout", protocol+" "+version+" human-approved session adapter for external assistants or browser users. Requires AI enabled, Cloud billing build, hosted mode, authoritative leader/store and configured Stripe Checkout. External assistants use an API-resource OAuth bearer token with explicit billing:write consent and current team.manage_settings authority on the consented team; billing-only consent does not require a site. Browser sessions retain current active-team billing administration. API-client tokens are unsupported. The consented team remains pinned if the dashboard active team changes, and revocation and current authority are checked before replaying a saved result. Requires escalation through continue_url for buyer approval and payment. Completion is reported only after Stripe retrieves complete and paid; this endpoint does not collect payment credentials or complete payment for the assistant. MPP, x402, AP2 and autonomous spending are not advertised.", append(secCookie(), map[string]any{"oauth2": []string{"billing:write"}}), parameters, body, map[string]any{success: jsonSchemaResp("Authoritative Checkout session", response), "400": desc("Invalid body, version, item or idempotency key"), "401": desc("Valid browser session or user-delegated OAuth token required"), "403": desc("Explicit billing:write consent and current billing administration required"), "404": desc("Disabled feature or session outside the authorized team"), "409": desc("Conflicting checkout, unavailable price, or UCP idempotency mismatch"), "422": desc("ACP idempotency payload mismatch or malformed/incompatible UCP profile"), "424": desc("UCP profile fetch failed"), "405": desc("Checkout cannot be canceled"), "501": desc("Payment rail is not implemented/configured"), "502": desc("Stripe request failed"), "503": desc("Authoritative store, leader or provider unavailable")}) + operation["x-hitkeep-ai-enabled"] = true + delete(operation, "x-internal") + path := base + route.suffix + if paths[path] == nil { + paths[path] = map[string]any{} + } + paths[path].(map[string]any)[route.method] = operation + } + } + return paths +} diff --git a/server/system/api_docs_commerce_test.go b/server/system/api_docs_commerce_test.go new file mode 100644 index 00000000..b72fd31b --- /dev/null +++ b/server/system/api_docs_commerce_test.go @@ -0,0 +1,54 @@ +package system + +import ( + "reflect" + "strings" + "testing" + + "hitkeep/config" + "hitkeep/database" + "hitkeep/server/cloud" + "hitkeep/server/shared" +) + +func TestCommerceOpenAPIUsesDelegatedBillingAndHumanPayment(t *testing.T) { + paths := openAPIV1CommercePaths() + for path, raw := range paths { + if strings.Contains(path, "mpp") || strings.Contains(path, "x402") { + t.Fatalf("advertised unimplemented rail %s", path) + } + for method, raw := range raw.(map[string]any) { + operation := raw.(map[string]any) + if operation["x-internal"] == true { + t.Fatalf("%s %s hides supported offsite commerce", method, path) + } + if !reflect.DeepEqual(operation["security"], append(secCookie(), map[string]any{"oauth2": []string{"billing:write"}})) { + t.Fatalf("%s %s lacks delegated billing authorization", method, path) + } + description := operation["description"].(string) + for _, text := range []string{"AI enabled", "continue_url", "complete and paid", "team.manage_settings", "API-resource OAuth bearer token", "billing:write", "consented team", "revocation", "does not collect payment credentials"} { + if !strings.Contains(description, text) { + t.Fatalf("missing %s in %s", text, path) + } + } + } + } + if len(paths) != 8 { + t.Fatalf("checkout paths=%d", len(paths)) + } + if OpenAPISpecV1("https://analytics.example.com")["paths"].(map[string]any)["/api/cloud/billing/checkout"].(map[string]any)["post"].(map[string]any)["x-internal"] != true { + t.Fatal("dashboard-only billing must remain internal") + } +} + +func TestRuntimeCommerceDoesNotAdvertiseUnconfiguredOAuth(t *testing.T) { + conf := &config.Config{AIEnabled: true, CloudHosted: true, StripeSecretKey: "fixture-key", StripePriceProMonthly: "fixture-price"} + if !cloud.CommerceAvailable(conf) { + t.Skip("requires billing build") + } + spec := OpenAPISpecV1ForRuntime("https://analytics.example.com", &shared.Context{Config: conf, Store: &database.Store{}}) + operation := spec["paths"].(map[string]any)["/api/cloud/commerce/acp/checkout_sessions"].(map[string]any)["post"].(map[string]any) + if !reflect.DeepEqual(operation["security"], secCookie()) { + t.Fatal("unconfigured OAuth advertised alongside available browser Checkout") + } +} diff --git a/server/system/api_docs_discovery.go b/server/system/api_docs_discovery.go new file mode 100644 index 00000000..2c75eeb8 --- /dev/null +++ b/server/system/api_docs_discovery.go @@ -0,0 +1,24 @@ +package system + +// agentDiscoveryPaths describes optional AI-enabled public discovery and analytics. +// Runtime callers include these paths only when the instance AI feature flag is on. +func agentDiscoveryPaths() map[string]any { + paths := map[string]any{} + for route, media := range map[string]string{ + "/.well-known/agent-skills/index.json": "application/json", + "/.well-known/skills/index.json": "application/json", + "/.well-known/poppy.json": "application/json", + "/.well-known/agent-card.json": "application/json", + "/auth.md": "text/markdown", + "/llms.txt": "text/markdown", + } { + kind := "string" + if media == "application/json" { + kind = "object" + } + paths[route] = map[string]any{"get": op([]string{"Integrations"}, "Read agent discovery", "Requires AIEnabled and a valid canonical public URL. Poppy is website-only version 0.2. A2A card additionally requires ready OAuth, leader, control store and tenant analytics routing. Discovery grants no account access.", nil, nil, nil, map[string]any{"200": map[string]any{"description": "Public discovery representation", "content": map[string]any{media: map[string]any{"schema": map[string]any{"type": kind}}}}, "404": desc("Feature or representation unavailable")})} + } + paths["/skills/{skill}/skill.zip"] = map[string]any{"get": op([]string{"Integrations"}, "Download canonical product skill", "Requires AIEnabled. Deterministic ZIP containing root SKILL.md and references/procedure.md only. v0.2 index digest is SHA-256 of these raw bytes.", nil, []any{map[string]any{"name": "skill", "in": "path", "required": true, "schema": map[string]any{"type": "string"}}}, nil, map[string]any{"200": map[string]any{"description": "Skill archive", "content": map[string]any{"application/zip": map[string]any{"schema": map[string]any{"type": "string", "format": "binary"}}}}, "404": desc("Unknown skill or disabled feature")})} + paths["/api/a2a/message:send"] = map[string]any{"post": op([]string{"Integrations"}, "Read synchronous A2A analytics", "A2A 1.0 HTTP+JSON. Send one ROLE_USER message containing one data part {tool, arguments}; tool names and input schemas are in the agent card. Executes the existing read-only aggregate registry with 31-day range, 64 KiB request and 15-second execution bounds. Requires AIEnabled, ready OAuth, both sites:read and analytics:read for the API resource, current site permission and explicit consent. API-client tokens and dashboard cookies are unsupported. Returns a direct ROLE_AGENT message, no persisted tasks, model calls, streaming or push notifications.", []any{map[string]any{"oauth2": []string{"sites:read", "analytics:read"}}}, []any{map[string]any{"name": "A2A-Version", "in": "header", "required": true, "schema": map[string]any{"type": "string", "const": "1.0"}}}, map[string]any{"required": true, "content": map[string]any{"application/a2a+json": map[string]any{"schema": map[string]any{"type": "object"}, "example": map[string]any{"message": map[string]any{"messageId": "request-1", "role": "ROLE_USER", "parts": []any{map[string]any{"data": map[string]any{"tool": "hitkeep_get_site_overview", "arguments": map[string]any{"site_id": "00000000-0000-4000-8000-000000000001", "sections": []string{"kpis"}}}}}}}}}}, map[string]any{"200": desc("Direct structured analytics message"), "400": desc("Invalid message, unsupported version or configuration"), "401": desc("OAuth authentication required"), "403": desc("Consent, scope or site permission denied"), "404": desc("Unavailable feature or task")})} + return paths +} diff --git a/server/system/api_docs_discovery_test.go b/server/system/api_docs_discovery_test.go new file mode 100644 index 00000000..9404d9a9 --- /dev/null +++ b/server/system/api_docs_discovery_test.go @@ -0,0 +1,31 @@ +package system + +import ( + "hitkeep/config" + "hitkeep/server/shared" + "testing" +) + +func TestRuntimeOpenAPIOptionalAvailability(t *testing.T) { + for _, enabled := range []bool{false, true} { + ctx := &shared.Context{Config: &config.Config{AIEnabled: enabled}} + paths := OpenAPISpecV1ForRuntime("https://example.test", ctx)["paths"].(map[string]any) + if (paths["/.well-known/agent-skills/index.json"] != nil) != enabled { + t.Fatalf("AI=%v index availability", enabled) + } + for _, route := range []string{"/api/a2a/message:send", "/.well-known/agent-card.json", "/api/cloud/commerce/acp/checkout_sessions"} { + if paths[route] != nil { + t.Fatalf("unavailable handler advertised: %s", route) + } + } + if paths["/api/status"] == nil || paths["/oauth/token"] == nil { + t.Fatal("existing REST/OAuth paths lost") + } + } + if OpenAPISpecV1("https://example.test")["paths"].(map[string]any)["/api/a2a/message:send"] == nil { + t.Fatal("static reference lost optional API") + } + if OpenAPISpecV1ForRuntime("https://example.test", nil)["paths"] == nil { + t.Fatal("nil runtime context lost ordinary documentation") + } +} diff --git a/server/system/api_docs_handlers.go b/server/system/api_docs_handlers.go index 18d4a7a2..99cd75f2 100644 --- a/server/system/api_docs_handlers.go +++ b/server/system/api_docs_handlers.go @@ -44,7 +44,7 @@ func (h *handler) handleGetAPIDocV1() http.HandlerFunc { publicURL = "http://localhost:8080" } - document, err := json.Marshal(OpenAPISpecV1(publicURL)) + document, err := json.Marshal(OpenAPISpecV1ForRuntime(publicURL, h.ctx)) if err != nil { // The specification is assembled from plain JSON types. panic("system: cannot encode OpenAPI document: " + err.Error()) diff --git a/server/system/api_docs_handlers_test.go b/server/system/api_docs_handlers_test.go index fb09d63b..3225dcc5 100644 --- a/server/system/api_docs_handlers_test.go +++ b/server/system/api_docs_handlers_test.go @@ -359,12 +359,15 @@ func TestOpenAPISpecV1IncludesCloudSignupPaths(t *testing.T) { } } -func TestOpenAPISpecV1MarksEveryCloudOperationInternal(t *testing.T) { +func TestOpenAPISpecV1MarksDashboardCloudOperationsInternal(t *testing.T) { spec := openAPISpecV1("http://localhost:8080") paths := requireMap(t, spec, "paths") cloudOperations := 0 for path, rawPathItem := range paths { + if strings.HasPrefix(path, "/api/cloud/commerce/") { + continue + } pathItem, ok := rawPathItem.(map[string]any) if !ok { continue diff --git a/server/system/api_docs_oauth.go b/server/system/api_docs_oauth.go index 6c3ec63c..bb8c1777 100644 --- a/server/system/api_docs_oauth.go +++ b/server/system/api_docs_oauth.go @@ -10,12 +10,17 @@ func oauthSecurityScheme(publicURL string) map[string]any { issuer := strings.TrimRight(publicURL, "/") return map[string]any{ "type": "oauth2", - "description": "Authorization Code with mandatory PKCE S256. Access is bound to one API or MCP resource, one team, explicitly consented sites, scopes, and the user's current permissions. Dashboard administration does not accept OAuth tokens. Existing API-client credentials remain supported separately.", + "description": "Authorization Code with mandatory PKCE S256. Access is bound to one API or MCP resource, one team, explicitly consented sites, scopes, and the user's current permissions. AI-enabled ordinary REST actions accept only their documented scopes. Billing-only consent may select a team without sites and still requires human payment approval. Credentials, impersonation, raw exports and instance administration are excluded. Existing API-client credentials remain supported separately; MCP remains read-only.", "flows": map[string]any{"authorizationCode": map[string]any{ "authorizationUrl": issuer + "/oauth/authorize", "tokenUrl": issuer + "/oauth/token", "scopes": map[string]string{ "sites:read": "Discover granted sites.", "analytics:read": "Read approved aggregate analytics.", "tracking_domains:read": "Discover eligible active tracking domains.", "ingest:write": "Submit server-side analytics for granted sites.", + "goals:write": "Create, update and delete goals and funnels on consented sites.", + "annotations:write": "Create, update and delete timeline annotations on consented sites.", + "sites:write": "Change consented site domains, retention and exclusions.", + "opportunities:write": "Generate opportunities and change their status on consented sites.", + "billing:write": "Manage approved team Checkout sessions; payment requires human approval.", }, }}, } diff --git a/server/system/api_docs_openapi.go b/server/system/api_docs_openapi.go index b1dc200e..feb0307c 100644 --- a/server/system/api_docs_openapi.go +++ b/server/system/api_docs_openapi.go @@ -1,7 +1,60 @@ package system +import ( + "maps" + "strings" + + "hitkeep/server/cloud" + "hitkeep/server/shared" +) + func OpenAPISpecV1(publicURL string) map[string]any { - return openAPISpecV1(publicURL) + spec := openAPISpecV1(publicURL) + paths := spec["paths"].(map[string]any) + addOAuthActionSecurity(paths) + maps.Copy(paths, agentDiscoveryPaths()) + return spec +} + +// OpenAPISpecV1ForRuntime advertises optional handlers only when their runtime prerequisites hold. +func OpenAPISpecV1ForRuntime(publicURL string, ctx *shared.Context) map[string]any { + spec := OpenAPISpecV1(publicURL) + paths := spec["paths"].(map[string]any) + if ctx == nil || ctx.Config == nil || !ctx.Config.AIEnabled || ctx.OAuthProvider == nil || !ctx.OAuthProvider.Ready() { + scopes := spec["components"].(map[string]any)["securitySchemes"].(map[string]any)["oauth2"].(map[string]any)["flows"].(map[string]any)["authorizationCode"].(map[string]any)["scopes"].(map[string]string) + for _, scope := range []string{"goals:write", "annotations:write", "sites:write", "opportunities:write", "billing:write"} { + delete(scopes, scope) + } + for path, item := range paths { + for _, value := range item.(map[string]any) { + operation, ok := value.(map[string]any) + if !ok || (operation["x-hitkeep-oauth-scope"] == nil && !strings.HasPrefix(path, "/api/cloud/commerce/")) { + continue + } + security := operation["security"].([]any) + operation["security"] = security[:len(security)-1] + delete(operation, "x-hitkeep-oauth-scope") + } + } + } + if ctx == nil || ctx.Config == nil || !ctx.Config.AIEnabled { + for route := range agentDiscoveryPaths() { + delete(paths, route) + } + } + if !ctx.A2AAvailable() { + delete(paths, "/.well-known/agent-card.json") + delete(paths, "/api/a2a/message:send") + } + commerce := ctx != nil && cloud.CommerceAvailable(ctx.Config) && ctx.Store != nil && (ctx.Cluster == nil || ctx.Cluster.IsLeader()) + if !commerce { + for route := range paths { + if strings.HasPrefix(route, "/api/cloud/commerce/") { + delete(paths, route) + } + } + } + return spec } func openAPISpecV1(publicURL string) map[string]any { @@ -22,7 +75,7 @@ func openAPISpecV1(publicURL string) map[string]any { {"name": "Auth", "description": "Authentication, password, and sign-in flows."}, {"name": "OAuth", "description": "Fosite authorization, consent, and connected applications."}, {"name": "Integrations", "description": "Scoped site discovery and integration connections."}, - {"name": "Cloud", "description": "Internal managed HitKeep Cloud application endpoints; they are not part of the supported public API. Available only in hosted cloud builds with billing enabled."}, + {"name": "Cloud", "description": "Managed Cloud endpoints, available only in hosted cloud builds with billing enabled. Operations marked x-internal are internal application interfaces; ACP/UCP commerce supports external assistants with explicit OAuth billing consent."}, {"name": "User", "description": "Authenticated user profile, preferences, and security endpoints."}, {"name": "Permissions", "description": "Authenticated permission context endpoints."}, {"name": "Admin", "description": "Instance-level admin and membership management endpoints."}, diff --git a/server/system/api_docs_paths.go b/server/system/api_docs_paths.go index 85689136..3d733417 100644 --- a/server/system/api_docs_paths.go +++ b/server/system/api_docs_paths.go @@ -3,6 +3,7 @@ package system func openAPIV1Paths() map[string]any { return mergeOpenAPIPathMaps( openAPIV1CorePaths(), + openAPIV1CommercePaths(), openAPIV1AdminSitePaths(), openAPIV1WebVitalPaths(), openAPIV1IntegrationPaths(), @@ -43,6 +44,7 @@ func openAPIV1CorePaths() map[string]any { "200": jsonSchemaResp("Status payload", map[string]any{ "type": "object", "properties": map[string]any{ + "ai_enabled": map[string]any{"type": "boolean", "description": "Raw instance AI feature flag, independent of Ask AI and provider configuration."}, "needs_setup": map[string]any{"type": "boolean"}, "version": map[string]any{"type": "string"}, "cloud": map[string]any{"$ref": "#/components/schemas/CloudStatus"}, diff --git a/server/system/api_docs_schemas_account.go b/server/system/api_docs_schemas_account.go index 06d35e04..25fd9135 100644 --- a/server/system/api_docs_schemas_account.go +++ b/server/system/api_docs_schemas_account.go @@ -720,6 +720,7 @@ func openAPIV1AccountSchemas() map[string]any { "SystemStatus": map[string]any{ "type": "object", "properties": map[string]any{ + "ai_enabled": map[string]any{"type": "boolean", "description": "Raw instance AI feature flag, independent of Ask AI and provider configuration."}, "needs_setup": map[string]any{"type": "boolean"}, "version": map[string]any{"type": "string"}, "cloud": map[string]any{"$ref": "#/components/schemas/CloudStatus"}, diff --git a/skills/README.md b/skills/README.md index ed5dd796..9e92ec38 100644 --- a/skills/README.md +++ b/skills/README.md @@ -16,6 +16,8 @@ Each `SKILL.md` is a thin external-agent adapter for production HitKeep MCP. Eac Open **Integration → Assistants** (`/integration/assistants`) in HitKeep to copy the canonical MCP endpoint and find setup links. Approve only the MCP resource, `sites:read analytics:read`, and the sites you intend to share. Review or revoke your OAuth grant under **Settings → Connected applications**. The configuration display does not verify a working client connection; existing scoped API-client tokens remain available for compatible clients. +External assistants that also perform product actions use a separate API-resource OAuth grant. Discover the instance's `/auth.md` and `/openapi.json`; operations marked `x-hitkeep-oauth-scope` accept the named scope with explicit consent and current user/team/site permissions while AI is enabled. Supported writes cover goals/funnels, annotations, site domain/retention/exclusions, opportunities and configured team Checkout; billing-only consent does not require sites, and payment still needs human approval. This does not expand the five analytics skills or the read-only MCP tools, and does not authorize credentials, impersonation, raw exports or instance administration. + Skill installation supplies instructions; a custom MCP connection supplies access. The [assistant distribution repository](https://github.com/HitKeep/hitkeep-assistant-plugins) generates native Claude and Codex packages from exactly these five product skills, their procedures, agent metadata, and the license at a recorded app commit. Stable app releases include one verified `hitkeep-analytics-X.Y.Z.zip`; the distribution's versioned release links to that asset. Native manifests and catalogs remain owned by the distribution repository. Contributor skills are excluded, and Ask AI continues to embed only the five transport-neutral procedure references. Offline fixture metadata and prepared review cases live in [`tests/agent-plugins`](../tests/agent-plugins/README.md). Packaging and fixture checks do not prove installation in a particular client, authenticated analytics access, live assistant behavior, or public directory eligibility. Verify installation and the scoped MCP connection separately. diff --git a/skills/public.go b/skills/public.go index d5ad0841..76aa6b4b 100644 --- a/skills/public.go +++ b/skills/public.go @@ -1,9 +1,18 @@ package skills import ( + "archive/zip" + "bytes" + "crypto/sha256" "embed" + "fmt" "io/fs" "strings" + "time" + + "go.yaml.in/yaml/v3" + + "hitkeep/appurl" ) // External adapters are served separately and never enter the Ask AI procedure pack. @@ -24,3 +33,73 @@ func PublicFile(name string) ([]byte, error) { } return nil, fs.ErrNotExist } + +// DiscoveryIndex describes the v0.2 artifacts fetched by external skill clients. +type DiscoveryIndex struct { + Schema string `json:"$schema"` + Skills []DiscoverySkill `json:"skills"` +} + +type DiscoverySkill struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + URL string `json:"url"` + Digest string `json:"digest"` +} + +// PublicArchive packages only the canonical adapter and its procedure, at the archive root. +func PublicArchive(name string) ([]byte, error) { + var data bytes.Buffer + archive := zip.NewWriter(&data) + for _, file := range []string{"SKILL.md", "references/procedure.md"} { + content, err := PublicFile(name + "/" + file) + if err != nil { + return nil, err + } + header := &zip.FileHeader{Name: file, Method: zip.Store, Modified: time.Date(1980, time.January, 1, 0, 0, 0, 0, time.UTC)} + header.SetMode(0644) + writer, err := archive.CreateHeader(header) + if err != nil { + return nil, err + } + if _, err = writer.Write(content); err != nil { + return nil, err + } + } + if err := archive.Close(); err != nil { + return nil, err + } + return data.Bytes(), nil +} + +// PublicIndex derives descriptions and digests from the exact canonical artifacts. +func PublicIndex(base string) (DiscoveryIndex, error) { + index := DiscoveryIndex{Schema: "https://schemas.agentskills.io/discovery/0.2.0/schema.json", Skills: []DiscoverySkill{}} + for _, procedure := range AnalyticsProcedures() { + adapter, err := PublicFile(procedure.Name + "/SKILL.md") + if err != nil { + return index, err + } + _, frontmatter, ok := strings.Cut(string(adapter), "---\n") + if !ok { + return index, fmt.Errorf("skill %s has no frontmatter", procedure.Name) + } + frontmatter, _, ok = strings.Cut(frontmatter, "\n---") + if !ok { + return index, fmt.Errorf("skill %s has no frontmatter boundary", procedure.Name) + } + var metadata struct { + Description string `yaml:"description"` + } + if err := yaml.Unmarshal([]byte(frontmatter), &metadata); err != nil { + return index, err + } + artifact, err := PublicArchive(procedure.Name) + if err != nil { + return index, err + } + index.Skills = append(index.Skills, DiscoverySkill{Name: procedure.Name, Type: "archive", Description: metadata.Description, URL: appurl.Path(base, "/skills/"+procedure.Name+"/skill.zip"), Digest: fmt.Sprintf("sha256:%x", sha256.Sum256(artifact))}) + } + return index, nil +} diff --git a/skills/public_test.go b/skills/public_test.go index 6a2b4f37..217e5356 100644 --- a/skills/public_test.go +++ b/skills/public_test.go @@ -1,7 +1,11 @@ package skills import ( + "archive/zip" "bytes" + "crypto/sha256" + "fmt" + "io" "os" "strings" "testing" @@ -37,3 +41,60 @@ func TestPublicSkillsUseCanonicalBytesAndRejectOtherFiles(t *testing.T) { t.Fatal("external adapter entered Ask AI") } } + +func TestPublicSkillArchivesAndIndex(t *testing.T) { + index, err := PublicIndex("https://example.test/tenant") + if err != nil { + t.Fatal(err) + } + if index.Schema != "https://schemas.agentskills.io/discovery/0.2.0/schema.json" || len(index.Skills) != 5 { + t.Fatalf("index: %+v", index) + } + for _, entry := range index.Skills { + data, err := PublicArchive(entry.Name) + if err != nil { + t.Fatal(err) + } + repeated, err := PublicArchive(entry.Name) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(data, repeated) || entry.Digest != fmt.Sprintf("sha256:%x", sha256.Sum256(data)) || entry.Type != "archive" || entry.Description == "" || entry.URL != "https://example.test/tenant/skills/"+entry.Name+"/skill.zip" { + t.Fatalf("artifact: %+v", entry) + } + archive, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatal(err) + } + if len(archive.File) != 2 { + t.Fatalf("archive has %d files", len(archive.File)) + } + for i, name := range []string{"SKILL.md", "references/procedure.md"} { + file := archive.File[i] + if file.Name != name || !file.Mode().IsRegular() { + t.Fatalf("unsafe archive file: %+v", file) + } + reader, err := file.Open() + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(reader) + reader.Close() + if err != nil { + t.Fatal(err) + } + want, err := PublicFile(entry.Name + "/" + name) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatal("archive changed canonical bytes") + } + } + } + for _, name := range []string{"../hitkeep-analytics", "hitkeep-development", "hitkeep-analytics/../hitkeep-analytics"} { + if _, err := PublicArchive(name); err == nil { + t.Errorf("accepted %q", name) + } + } +}