Documentation
¶
Overview ¶
Package integrations is the generic, provider-agnostic OAuth connector plane for the unified Hanzo Cloud binary — the /v1/integrations surface that lets an org connect a third-party account (Slack today; GitHub scaffolded; Google / Salesforce plug into the SAME registry later) and hands the resulting per-org tokens to KMS custody.
ONE framework, N providers. A provider self-registers (its file's init calls register) into a package registry declaring how to build its authorize URL, exchange the code, and revoke. The five HTTP handlers here are provider-blind: they resolve the provider by :provider, apply the SAME org gate, CSRF/state, KMS custody and console redirect for every one. Adding a provider is a new file, never a new route.
Surface (subsystem name "integrations", prefix /v1/integrations):
GET /v1/integrations list providers + this org's status -> {providers:[...]}
GET /v1/integrations/:provider one provider (404 unknown id) -> Provider
POST /v1/integrations/:provider/connect begin OAuth (org-authed) -> {authorizeUrl} | 503 | 403
GET /v1/integrations/:provider/callback PUBLIC, state-authed -> 302 to console
POST /v1/integrations/:provider/disconnect revoke + forget (org-authed) -> {disconnected:true}
TENANT ISOLATION. connect/list/get/disconnect derive the org from principal.Org (a VALIDATED principal — a client-forged X-Org-Id with no bearer is refused 403). The callback is Slack/GitHub-initiated and therefore UNAUTHENTICATED, so its org is taken ONLY from the HMAC-signed, single-use state — never a header (see state.go). Every org that reaches KMS or the store is additionally validOrg-checked so it can never smuggle path structure into a secret key.
SECRET CUSTODY. Per-org customer tokens live ONLY in KMS (sealed, AES-256-GCM envelope), keyed /orgs/{org}/integrations/{provider}. The store holds only non-secret connection metadata (external id, account label, granted scopes). Provider APP creds (client id/secret) come from ENV, injected by the operator from KMS via KMSSecret — never plaintext in the store or a manifest. If KMS is not Ready the connect/callback flows fail closed (503 / failure redirect); a token is NEVER written in plaintext and NEVER put in SQLite.
Index ¶
- func InstallationToken(ctx context.Context, org string) (string, error)
- func Mount(app *zip.App, deps cloud.Deps) error
- func NotifySlack(ctx context.Context, org, channel, text string, blocks []any) error
- func OrgForExternalID(provider, externalID string) (string, bool)
- func PostSlackBlocks(ctx context.Context, botToken, channel, text string, blocks []any) error
- func PostSlackBlocksThread(ctx context.Context, botToken, channel, threadTS, text string, blocks []any) error
- func SetCodingDispatcher(d coding.Dispatcher)
- func Shutdown(_ context.Context) error
- func TokenFor(ctx context.Context, org, provider, name string) ([]byte, error)
- type Connection
- type ExchangeResult
- type Inbound
- type OAuthConfig
- type Provider
- type Store
- func (s *Store) ClaimNonce(ctx context.Context, nonce, provider string) (string, bool, error)
- func (s *Store) Close() error
- func (s *Store) ConsumeNonce(ctx context.Context, nonce, org, provider string) (bool, error)
- func (s *Store) Delete(ctx context.Context, org, provider string) (bool, error)
- func (s *Store) GCEvents(ctx context.Context, before int64) (int64, error)
- func (s *Store) GCNonces(ctx context.Context, before int64) (int64, error)
- func (s *Store) Get(ctx context.Context, org, provider string) (Connection, bool, error)
- func (s *Store) List(ctx context.Context, org string) ([]Connection, error)
- func (s *Store) MarkEvent(ctx context.Context, provider, key string) (bool, error)
- func (s *Store) PutNonce(ctx context.Context, nonce, org, provider string) error
- func (s *Store) ResolveOrgByExternalID(ctx context.Context, provider, externalID string) (string, bool, error)
- func (s *Store) Upsert(ctx context.Context, c Connection) error
- type SyncHook
- type VerifyInput
- type WritebackHook
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func InstallationToken ¶ added in v1.801.23
InstallationToken mints a fresh short-lived GitHub App installation token for org's connected installation. Fails closed (error, never a value) when GitHub is not connected for org or the App creds are absent. Called by the git object plane (outbound mirror) and the sync handlers below.
func Mount ¶
Mount wires /v1/integrations/* onto app. Complex flavour: it publishes the package global `mounted` (the in-process token-custody seam) and pairs with a Shutdown, so it constructs the cloud.Service value directly (cloud.NewBase + &cloud.Service[state]{…}) rather than via cloud.Mount.
func NotifySlack ¶ added in v1.800.1
NotifySlack posts a Block Kit message to channel on behalf of org's connected Slack workspace. It resolves the org's KMS-sealed bot token (TokenFor — fail-closed: unmounted / org not connected / KMS-down all error, never post) and delivers through PostSlackBlocks. The caller never handles the raw token. This is the door the git-lifecycle notifier (clients/git) uses so token custody stays entirely inside the integrations plane.
func OrgForExternalID ¶
OrgForExternalID resolves a provider account id (Slack team_id / GitHub installation_id) back to the org that connected it. Used by inbound provider events (which carry the external id, not an org) to find the tenant.
func PostSlackBlocks ¶ added in v1.800.1
PostSlackBlocks posts a Block Kit message (with a text fallback shown in notifications) to channel using botToken, via the shared chat.postMessage path. blocks is Block Kit JSON (a []any of section/context/… maps); nil posts text only. Exported so a subsystem holding a resolved bot token (an automations connector) posts through the SAME code path as the OAuth bridge — no third chat.postMessage implementation.
func PostSlackBlocksThread ¶ added in v1.801.23
func PostSlackBlocksThread(ctx context.Context, botToken, channel, threadTS, text string, blocks []any) error
PostSlackBlocksThread posts a Block Kit message threaded under threadTS (when non-empty) via the shared chat.postMessage path — the same door PostSlackBlocks uses, plus in-thread delivery so a coding result lands under the triggering @hanzo message.
func SetCodingDispatcher ¶ added in v1.801.23
func SetCodingDispatcher(d coding.Dispatcher)
SetCodingDispatcher injects the coding orchestrator. Called once at wiring time by the composition root; production never reassigns it.
Types ¶
type Connection ¶
type Connection struct {
Org string
Provider string
ExternalID string
AccountLabel string
BotUserID string
Scopes []string
ConnectedAt int64
UpdatedAt int64
}
Connection is an org's non-secret link to a provider account. The token itself is NEVER here — it lives in KMS, keyed by (org,provider); this row holds only the metadata needed to render the card and route inbound events.
func ConnectionFor ¶
func ConnectionFor(org, provider string) (Connection, bool)
ConnectionFor returns an org's non-secret connection metadata for a provider.
NOTE (single contract deviation): the contract names this seam `Connection`, but Go forbids a func and a type sharing an identifier and `Connection` is the domain-noun TYPE (used by SyncHook/WritebackHook, the store, and this return value). The accessor is therefore `ConnectionFor` — the idiomatic Go name for "the Connection for (org,provider)". The bridge calls integrations.ConnectionFor.
type ExchangeResult ¶
type ExchangeResult struct {
Tokens map[string]string // secret name -> value (sealed into KMS)
ExternalID string // provider account id (Slack team.id / GitHub installation_id)
AccountLabel string // human label (Slack team.name / GitHub org login)
BotUserID string // Slack bot_user_id (non-secret)
Scopes []string // granted scopes
}
ExchangeResult is what a provider's Exchange returns after trading the OAuth code for tokens. Tokens is a map of KMS secret-name -> secret-value; each entry is sealed into the org's KMS namespace. ExternalID/AccountLabel/BotUserID/Scopes are NON-secret and land in the connection row.
type Inbound ¶ added in v1.801.30
type Inbound struct {
Provider string // registry slug: "slack","teams","discord","telegram"
ExternalID string // workspace/tenant/guild/chat id → OrgForExternalID (isolation root)
User string // platform-verified user id (billing/attribution subject via the link)
Channel string // reply target (channel/conversation/chat id)
ThreadID string // thread/message id to reply under (optional)
Text string // the user's prompt, mention stripped
DedupeKey string // event/update/interaction id ("" ⇒ non-dedupable)
}
Inbound is the normalized inbound chat event — ONE shape for every platform. An adapter produces it AFTER it has authenticated the request and parsed the payload. The core never sees a raw platform payload.
type OAuthConfig ¶
OAuthConfig is a provider's resolved APP credentials, read from ENV at request time by the provider's Creds func. ClientID/ClientSecret cover OAuth2; Extra carries provider-specific non-secret config (e.g. the GitHub App slug). It is NEVER persisted — it lives only for the duration of one authorize/exchange.
type Provider ¶
type Provider struct {
ID string // stable slug, the :provider path segment ("slack","github")
Name string // display name
Description string // one-line card copy
Category string // grouping ("Communication","Developer",...)
Scopes []string // requested scopes (display + authorize URL)
RedirectPath string // OAuth redirect path; MUST equal /v1/integrations/{id}/callback
Secrets []string // KMS secret names this provider custodies (deleted on disconnect)
// Kind selects credential acquisition. Empty/"oauth" (default) uses the
// 3-legged Authorize/Exchange flow. "apikey" (apiKeyKind) takes a
// customer-held credential submitted to /connect (from `hanzo connector add`,
// read on STDIN — never argv/URL), VERIFIES it live, and seals it to KMS; such
// providers use Verify, not Authorize/Exchange, and have no OAuth callback.
Kind string
// AdminOnly gates /connect and /disconnect on the caller being an admin of its
// OWN org (principal.IsOrgAdmin — NOT SuperAdmin), parity with the platform
// deploy-provider adminProcedure. OAuth social/chat providers leave it false.
AdminOnly bool
// Verify validates an apikey credential against the provider and returns the
// token(s) to seal + non-secret account metadata. It MUST fail closed (a
// bad/inactive credential returns an error and NOTHING is stored) and its error
// MUST NOT contain the credential value (it is logged). nil for oauth providers.
Verify func(ctx context.Context, in VerifyInput) (*ExchangeResult, error)
// Configured reports whether the provider's APP creds are present in ENV.
// When false: available=false in the card, and connect/callback fail closed
// with an honest 503 / failure redirect (never a dead-end, never a fake OK).
Configured func() bool
// Creds resolves the APP creds from ENV. Called only when Configured is true.
Creds func() OAuthConfig
// Authorize builds the provider's consent URL for (creds, redirectURI, state).
Authorize func(creds OAuthConfig, redirectURI, state string) (string, error)
// Exchange trades the OAuth code for tokens + account metadata.
Exchange func(ctx context.Context, creds OAuthConfig, redirectURI, code string) (*ExchangeResult, error)
// Revoke best-effort invalidates a token at the provider on disconnect. nil
// when the provider has no revoke endpoint.
Revoke func(ctx context.Context, creds OAuthConfig, token string) error
// #51 seams — declared, nil today, not wired to a route (see SyncHook/WritebackHook).
Sync SyncHook
Writeback WritebackHook
}
Provider is one connectable third-party. Everything provider-specific is a field here so the handlers stay provider-blind. The func fields read ENV at call time (not at init), so an operator can inject creds without a rebuild.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the integrations database. ONE SQLite file ({DataDir}/integrations.db) holds every org's connections + in-flight OAuth nonces; tenancy is the org column (PK includes it).
func (*Store) ClaimNonce ¶ added in v1.801.30
ClaimNonce atomically resolves the org bound to a nonce for `provider` and consumes it (single-use), returning the org. Unlike ConsumeNonce it does NOT know the org up front — it is the redemption side of a deep-link connect code (Telegram): the code arrives in a webhook that has not yet resolved a tenant, so the code ITSELF carries the org. found=false (nil error) when the code is unknown/already-claimed. The Store opens with MaxOpenConns(1) (store.go), so this read-then-delete pair runs with no concurrent writer — the DELETE's RowsAffected is the single-use proof.
func (*Store) ConsumeNonce ¶
ConsumeNonce atomically deletes the nonce bound to (org,provider) and reports whether exactly one row went. A second consume (replay) or a mismatched org/provider deletes zero rows → consumed=false. This single-DELETE-with-rows- affected IS the single-use proof (no read-then-delete race).
func (*Store) GCEvents ¶ added in v1.801.30
GCEvents reaps dedupe rows created before `before` (unix seconds), bounding the table's growth. Returns how many rows were removed. Called opportunistically from the webhook path so the table cannot accrete without bound.
func (*Store) GCNonces ¶
GCNonces deletes nonces created before `before` (unix seconds). Returns how many were reaped. Called opportunistically on connect so abandoned flows don't accrete.
func (*Store) Get ¶
Get returns the connection for (org,provider). found=false (nil error) when there is no row; a real DB error is returned as err.
func (*Store) MarkEvent ¶ added in v1.801.30
MarkEvent is the atomic durable dedupe test-and-set: it inserts (provider,key) and returns fresh=true only on the FIRST sighting. A duplicate (a platform retry of the same event/update/interaction id) hits the PRIMARY KEY, the insert affects zero rows, and fresh=false. An empty key is non-dedupable (fresh) — callers only dedupe non-empty keys onto the billed path. The single INSERT ... ON CONFLICT DO NOTHING + RowsAffected IS the single-use proof (no read-then-write race). A genuine DB error is surfaced so the caller fails CLOSED (skips the run) rather than risk a double charge.
func (*Store) PutNonce ¶
PutNonce records a single-use OAuth nonce bound to (org,provider). A duplicate nonce (astronomically unlikely from 128 bits) is a conflict, surfaced so connect fails rather than silently overwriting an in-flight one.
func (*Store) ResolveOrgByExternalID ¶
func (s *Store) ResolveOrgByExternalID(ctx context.Context, provider, externalID string) (string, bool, error)
ResolveOrgByExternalID maps a provider account id back to the connecting org. An empty externalID never matches (so unset/scaffold connections don't collide on ""). Ambiguity (two orgs, same external id — should not happen) resolves to the earliest-connected deterministically.
type SyncHook ¶
type SyncHook func(ctx context.Context, conn Connection) error
SyncHook pulls provider-side state INTO Hanzo (e.g. a GitHub App installation's repo list). It is a #51 seam: DECLARED on Provider, nil for every provider today, and NOT wired to any route. When GitHub creds land, github.go sets this to the installation-token-minting + repo-sync implementation.
type VerifyInput ¶ added in v1.801.79
VerifyInput is what an apikey provider's Verify receives: the customer's credential (from the /connect body, originally read on STDIN by `hanzo connector add`) plus an OPTIONAL non-secret account hint the caller may supply when the provider's own verify response cannot disclose it (e.g. a Cloudflare least-privilege token that can list neither its own name nor its account).
type WritebackHook ¶
type WritebackHook func(ctx context.Context, conn Connection, payload []byte) error
WritebackHook pushes Hanzo state TO the provider. It is a #51 seam: declared, nil today, not wired.
Source Files
¶
- bridge.go
- bridge_dedupe.go
- bridge_link.go
- bridge_state.go
- cloudflare.go
- discord.go
- discord_events.go
- discord_link.go
- github.go
- github_app.go
- github_webhook.go
- gitlab.go
- google.go
- integrations.go
- slack.go
- slack_coding.go
- slack_events.go
- slack_link.go
- slack_verify.go
- state.go
- store.go
- teams.go
- teams_events.go
- teams_link.go
- teams_verify.go
- telegram.go
- telegram_events.go
- telegram_link.go