feat(gmail): the OAuth client can be entered instead of deployed

The one click that opens Google's consent screen already existed. What it needs
behind it is a registered client, and no app can conjure one: Google issues no
Gmail access to an unregistered client, and `gmail.readonly` is a restricted
scope, so an id shipped inside a public image would be both extractable from it
and unusable without Google's security review of every deployment using it.

The registration is a one-off. Having to edit a Rune's manifest and recreate the
container to paste the result was not.

`VERDANDE_GMAIL_CLIENT_ID` and `_SECRET` still win when set. A value in the
environment is a deliberate deployment decision, applied again on every recreate,
and a form that could quietly override it would make the manifest a lie — so the
page says where the value came from, and PUT answers 409.

The redirect URI is spelled out on the page rather than left to be reconstructed.
It is derived from VERDANDE_BASE_URL, it has to match Google's copy exactly, and
the error Google returns when it does not names neither value.

Its own table rather than `user_settings`: an OAuth client is the instance's, not
one person's — and `user_settings` has `user_id` as its whole primary key, so a
second scope there would silently overwrite the AI provider's key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 09:05:18 +02:00
co-authored by Claude Opus 5
parent 101ca33069
commit 31bf0557df
5 changed files with 450 additions and 11 deletions
+41
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"github.com/kristianwind/verdande/internal/auth"
@@ -346,3 +347,43 @@ func (db *DB) SetUserSettings(ctx context.Context, userID, scope string, values
userID, scope, string(raw), time.Now().Unix())
return err
}
// --- instance-wide settings -------------------------------------------------------------
// InstanceSettings reads one scope of the instance's own settings. A missing row
// is an empty map, not an error.
//
// Separate from UserSettings because some configuration is the server's rather
// than a person's — the Gmail OAuth client is one registration that everybody
// connecting a mailbox goes through, not a preference each of them holds.
func (db *DB) InstanceSettings(ctx context.Context, scope string) (map[string]any, error) {
var raw string
err := db.QueryRowContext(ctx,
`SELECT values_json FROM instance_settings WHERE scope = ?`, scope).Scan(&raw)
if errors.Is(err, sql.ErrNoRows) {
return map[string]any{}, nil
}
if err != nil {
return nil, err
}
values := map[string]any{}
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return map[string]any{}, nil
}
return values, nil
}
func (db *DB) SetInstanceSettings(ctx context.Context, scope string, values map[string]any) error {
raw, err := json.Marshal(values)
if err != nil {
return err
}
_, err = db.ExecContext(ctx, `
INSERT INTO instance_settings (scope, values_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT (scope) DO UPDATE SET
values_json = excluded.values_json,
updated_at = excluded.updated_at`,
scope, string(raw), time.Now().Unix())
return err
}