From 57c1b248a53e5df355c0aa6c87951fca5de2882a Mon Sep 17 00:00:00 2001 From: zewelor Date: Sat, 21 Mar 2026 11:48:55 +0000 Subject: [PATCH 1/2] Make Url validator ActiveModel-compatible - Extract cron validation into `lib/r3x/triggers/concerns/cron_schedulable.rb` - Remove duplicate cron checks from `lib/r3x/triggers/schedule.rb` - Make `R3x::Validators::Url` an `ActiveModel::Validator` with `url_field` and `allow_blank` options and add `validate!` helper - Simplify URL error handling to consistently require HTTP/HTTPS - Add ActiveModel integration tests in `test/lib/r3x/validators/url_test.rb` and update `AGENTS.md` documentation --- Gemfile | 4 + Gemfile.lock | 8 + app/lib/r3x/client/google_auth.rb | 33 ++ bin/google-oauth | 401 +++++++++++++++++++++++++ config/initializers/inflections.rb | 1 + docs/todo/plan-gmail-output.md | 4 +- docs/todo/plan-google-sheets-client.md | 2 +- 7 files changed, 450 insertions(+), 3 deletions(-) create mode 100644 app/lib/r3x/client/google_auth.rb create mode 100755 bin/google-oauth diff --git a/Gemfile b/Gemfile index 260d6fc8..bf57cd3f 100644 --- a/Gemfile +++ b/Gemfile @@ -26,11 +26,15 @@ gem "google-cloud-translate" gem "googleauth" gem "google-apis-calendar_v3" gem "google-apis-gmail_v1" +gem "google-apis-sheets_v4" # Use the database-backed adapters for Rails.cache and Active Job gem "solid_cache" gem "solid_queue" +# CLI tools +gem "highline" + # Active Job dashboard (requires propshaft for API-only apps) gem "mission_control-jobs" gem "propshaft" diff --git a/Gemfile.lock b/Gemfile.lock index d94ff99a..ff46c3c1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -148,6 +148,8 @@ GEM retriable (~> 3.1) google-apis-gmail_v1 (0.47.0) google-apis-core (>= 0.15.0, < 2.a) + google-apis-sheets_v4 (0.47.0) + google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.8.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) @@ -239,6 +241,8 @@ GEM googleapis-common-protos (~> 1.7.0) grpc (~> 1.41) hashdiff (1.2.1) + highline (3.1.2) + reline i18n (1.14.8) concurrent-ruby (~> 1.0) importmap-rails (2.2.3) @@ -511,8 +515,10 @@ DEPENDENCIES faraday google-apis-calendar_v3 google-apis-gmail_v1 + google-apis-sheets_v4 google-cloud-translate googleauth + highline mission_control-jobs multi_json nokogiri @@ -573,6 +579,7 @@ CHECKSUMS google-apis-calendar_v3 (0.53.0) sha256=8ec7ef75362a5f938f1dac6d744e56856ed6d332467f1b5426b0771f5cd14ba9 google-apis-core (1.0.2) sha256=ba4579aaadc902d6cc7bc8db88f566ab00f5e31ea87ab41e9f9a032c470f2629 google-apis-gmail_v1 (0.47.0) sha256=3064434b6da55b85e2828ce4bb0f4d04e8cfd187a4ab262ceb1dcb01f98e49ef + google-apis-sheets_v4 (0.47.0) sha256=18b38419ea7365867af14a6cd4ad3000c1b4804fceb34417f98c8d303d097238 google-cloud-core (1.8.0) sha256=e572edcbf189cfcab16590628a516cec3f4f63454b730e59f0b36575120281cf google-cloud-env (2.3.1) sha256=0faac01eb27be78c2591d64433663b1a114f8f7af55a4f819755426cac9178e7 google-cloud-errors (1.6.0) sha256=1da8476dd706ad04b9d32e3c4b90d07d3463b37d6407cb56d41342ea7647d0a1 @@ -601,6 +608,7 @@ CHECKSUMS grpc (1.78.1-x86_64-linux-musl) sha256=4e90a3d0f2baa1cd70756ffe116bd81bb7b398ff774272828a23a89ac9a30b06 grpc-google-iam-v1 (1.11.0) sha256=8f0aa8a8503b3e001cb1561f31e43aa0445752fb675334afa1afac7f023f368c hashdiff (1.2.1) sha256=9c079dbc513dfc8833ab59c0c2d8f230fa28499cc5efb4b8dd276cf931457cd1 + highline (3.1.2) sha256=67cbd34d19f6ef11a7ee1d82ffab5d36dfd5b3be861f450fc1716c7125f4bb4a i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc diff --git a/app/lib/r3x/client/google_auth.rb b/app/lib/r3x/client/google_auth.rb new file mode 100644 index 00000000..ffa7ce76 --- /dev/null +++ b/app/lib/r3x/client/google_auth.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module R3x + module Client + module GoogleAuth + SCOPE_ALIASES = { + "gmail.readonly" => Google::Apis::GmailV1::AUTH_GMAIL_READONLY, + "gmail.send" => Google::Apis::GmailV1::AUTH_GMAIL_SEND, + "gmail.compose" => Google::Apis::GmailV1::AUTH_GMAIL_COMPOSE, + "gmail.modify" => Google::Apis::GmailV1::AUTH_GMAIL_MODIFY, + "sheets.readonly" => Google::Apis::SheetsV4::AUTH_SPREADSHEETS_READONLY, + "sheets" => Google::Apis::SheetsV4::AUTH_SPREADSHEETS, + "calendar.readonly" => Google::Apis::CalendarV3::AUTH_CALENDAR_READONLY, + "calendar" => Google::Apis::CalendarV3::AUTH_CALENDAR + }.freeze + + def self.from_json(parsed_json, scope:) + Signet::OAuth2::Client.new( + client_id: parsed_json.fetch("client_id"), + client_secret: parsed_json.fetch("client_secret"), + refresh_token: parsed_json.fetch("refresh_token"), + token_credential_uri: "https://oauth2.googleapis.com/token", + scope: Array(scope) + ).tap(&:fetch_access_token!) + end + + def self.resolve_scope(alias_or_scope) + key = alias_or_scope.to_s + SCOPE_ALIASES.fetch(key) { |k| k } + end + end + end +end diff --git a/bin/google-oauth b/bin/google-oauth new file mode 100755 index 00000000..df21c075 --- /dev/null +++ b/bin/google-oauth @@ -0,0 +1,401 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../config/environment" +require "optparse" +require "signet/oauth_2/client" +require "highline" + +SUBCOMMANDS = { + "authorize" => "Start OAuth2 authorization flow", + "status" => "Check credential status for a project", + "scopes" => "List available scope aliases" +}.freeze + +def print_help + puts "Usage: bin/google-oauth [options] [command]" + puts "" + puts "Options:" + puts " -h, --help Show this help" + puts "" + puts "Commands:" + SUBCOMMANDS.each do |cmd, desc| + puts " %-12s %s" % [cmd, desc] + end + puts "" + puts "PROJECT suffix:" + puts " The PROJECT argument is a suffix for environment variable names in Vault/.env" + puts "" + puts " Required env vars (pre-OAuth):" + puts " GOOGLE_CLIENT_ID_ - OAuth client ID from Google Cloud Console" + puts " GOOGLE_CLIENT_SECRET_ - OAuth client secret" + puts "" + puts " Required env var (post-OAuth):" + puts " GOOGLE_CREDENTIALS_ - JSON with client_id, client_secret, refresh_token" + puts "" + puts " Examples:" + puts " --project MYAPP → GOOGLE_CLIENT_ID_MYAPP, GOOGLE_CREDENTIALS_MYAPP" + puts " --project ACME → GOOGLE_CLIENT_ID_ACME, GOOGLE_CREDENTIALS_ACME" +end + +def checkmark + " \e[32m✓\e[0m" +end + +def crossmark + " \e[31m✗\e[0m" +end + +def step(text) + puts "" + puts text +end + +def success(text) + puts "#{checkmark} #{text}" +end + +def failure(text) + puts "#{crossmark} #{text}" +end + +def print_scopes + puts "" + puts "Available scope aliases:" + R3x::Client::GoogleAuth::SCOPE_ALIASES.each do |alias_name, scope| + puts " %-24s %s" % [alias_name, scope] + end + puts "" + puts "Example: --scopes gmail.readonly,sheets.readonly" +end + +def interactive_scope_selection + cli = HighLine.new + + puts "" + puts "=== Select Google API Scopes ===" + puts "" + + aliases = R3x::Client::GoogleAuth::SCOPE_ALIASES.keys + selected_aliases = [] + + loop do + # Show menu with remaining options + remaining = aliases - selected_aliases + + if remaining.empty? + puts "All available scopes selected." + break + end + + puts "Available scopes (selected: #{selected_aliases.size}):" + puts "" + + choice = cli.choose do |menu| + menu.prompt = "Select a scope (or 'done' to finish): " + + remaining.each do |alias_name| + scope = R3x::Client::GoogleAuth::SCOPE_ALIASES[alias_name] + menu.choice("#{alias_name} (#{scope})") { alias_name } + end + + menu.choice(:done) { nil } + end + + break if choice.nil? || choice == :done + + selected_aliases << choice + puts " ✓ Added: #{choice}" + puts "" + + # Break if all scopes selected + break if (aliases - selected_aliases).empty? + + # Ask if they want to add more + more = cli.ask("Add another scope? (y/n) ") { |q| q.default = "y" } + break unless more.downcase.start_with?("y") + end + + if selected_aliases.empty? + puts "" + puts "No scopes selected. Aborting." + abort + end + + puts "" + puts "Selected scopes:" + selected_aliases.each { |a| puts " • #{a}" } + + confirm = cli.ask("Proceed with authorization? (y/n) ") { |q| q.default = "y" } + + unless confirm.downcase.start_with?("y") + puts "Cancelled." + abort + end + + selected_aliases.join(",") +end + +def cmd_authorize(options) + project = options[:project] || abort("Error: --project is required\nUsage: bin/google-oauth authorize --project PROJECT [--scopes SCOPES]") + + # Get scopes - either from CLI or interactively + scope_aliases = if options[:scopes] && !options[:scopes].strip.empty? + options[:scopes] + else + interactive_scope_selection + end + + scopes = scope_aliases.split(",").map(&:strip).map do |s| + R3x::Client::GoogleAuth.resolve_scope(s) + end + + puts "" + puts "=== Google OAuth2 Authorization ===" + puts "" + puts "Project: #{project}" + puts "Scopes: #{scopes.join(', ')}" + + # --- Step 1: Load client credentials --- + step("[Step 1] Loading client credentials...") + + client_id_key = "GOOGLE_CLIENT_ID_#{project}" + client_secret_key = "GOOGLE_CLIENT_SECRET_#{project}" + + client_id = R3x::Env.fetch(client_id_key) + if client_id + success("Found #{client_id_key}") + else + failure("Missing #{client_id_key}") + puts "" + puts " Set it in Vault at: secret/data/env/r3x" + puts " Key: #{client_id_key}" + puts " Value: your Google OAuth client ID" + abort + end + + client_secret = R3x::Env.fetch(client_secret_key) + if client_secret + success("Found #{client_secret_key}") + else + failure("Missing #{client_secret_key}") + puts "" + puts " Set it in Vault at: secret/data/env/r3x" + puts " Key: #{client_secret_key}" + puts " Value: your Google OAuth client secret" + abort + end + + # --- Step 2: Build authorization URL --- + step("[Step 2] Generating authorization URL...") + + auth_client = Signet::OAuth2::Client.new( + client_id: client_id, + client_secret: client_secret, + redirect_uri: "http://localhost", + scope: scopes, + authorization_uri: "https://accounts.google.com/o/oauth2/auth" + ) + + auth_url = auth_client.authorization_uri.to_s + + puts "" + puts " Open this URL in your browser:" + puts "" + puts " #{auth_url}" + + # --- Step 3: Get authorization code --- + step("[Step 3] After granting access...") + puts " Google will redirect to a URL like:" + puts " http://localhost/?code=4/0AX4XfWh..." + puts "" + puts " The browser may show a connection error — that's expected." + puts " Copy the authorization code from the 'code' parameter in the URL." + + puts "" + print " Paste the authorization code: " + code = $stdin.gets&.chomp + + if code.nil? || code.strip.empty? + puts "" + failure("No code provided — aborting") + abort + end + + # --- Step 4: Exchange code for tokens --- + step("[Step 4] Exchanging code for tokens...") + + token_client = Signet::OAuth2::Client.new( + client_id: client_id, + client_secret: client_secret, + redirect_uri: "http://localhost", + code: code.strip, + token_credential_uri: "https://oauth2.googleapis.com/token" + ) + + begin + token_client.fetch_access_token! + rescue Signet::AuthorizationError => e + failure("Token exchange failed: #{e.message}") + puts "" + puts " Make sure you copied the full authorization code." + puts " Re-run this command to try again." + abort + rescue StandardError => e + failure("Unexpected error: #{e.class} — #{e.message}") + abort + end + + success("Access token obtained") + + # --- Step 5: Output credentials JSON --- + credentials = { + "client_id" => client_id, + "client_secret" => client_secret, + "refresh_token" => token_client.refresh_token + } + json_output = MultiJson.dump(credentials) + + step("[Step 5] Store these credentials in Vault") + vault_key = "GOOGLE_CREDENTIALS_#{project}" + puts "" + puts " Vault key: #{vault_key}" + puts "" + puts " #{json_output}" + puts "" + puts " Run:" + puts " vault kv put secret/data/env/r3x #{vault_key}='#{json_output}'" + puts "" + puts "Done! Test with: bin/google-oauth status --project #{project}" +end + +def cmd_status(options) + project = options[:project] || abort("Error: --project is required\nUsage: bin/google-oauth status --project PROJECT") + + puts "" + puts "=== Google OAuth2 Status ===" + puts "" + puts "Project: #{project}" + + # --- Step 1: Check credentials exist --- + step("[Step 1] Checking credentials...") + + credentials_key = "GOOGLE_CREDENTIALS_#{project}" + credentials_json = R3x::Env.fetch(credentials_key) + + unless credentials_json + failure("Missing #{credentials_key}") + puts "" + puts " Run authorization first:" + puts " bin/google-oauth authorize --project #{project} --scopes gmail.readonly,sheets.readonly" + abort + end + + success("Found #{credentials_key}") + + # --- Step 2: Parse JSON --- + step("[Step 2] Parsing credentials...") + + parsed = begin + MultiJson.load(credentials_json) + rescue MultiJson::ParseError => e + failure("Invalid JSON: #{e.message}") + abort + end + + %w[client_id client_secret refresh_token].each do |field| + if parsed[field].to_s.empty? + failure("Missing field: #{field}") + abort + end + end + + success("Credentials JSON is valid") + + # --- Step 3: Validate refresh token --- + step("[Step 3] Validating refresh token...") + + begin + R3x::Client::GoogleAuth.from_json(parsed, scope: "https://www.googleapis.com/auth/spreadsheets.readonly") + success("Token valid — access token obtained") + rescue Signet::AuthorizationError => e + failure("Token invalid: #{e.class} — #{e.message}") + puts "" + puts " The refresh token may have expired or been revoked." + puts " Re-run: bin/google-oauth authorize --project #{project} --scopes gmail.readonly,sheets.readonly" + abort + rescue KeyError => e + failure("Missing credential field: #{e.message}") + abort + rescue StandardError => e + failure("Unexpected error: #{e.class} — #{e.message}") + abort + end + + puts "" + puts "Status: Ready" +end + +# --- Parse global options --- +global_options = {} + +OptionParser.new do |opts| + opts.banner = "Usage: bin/google-oauth [options] [command]" + + opts.on("-h", "--help", "Show help") do + print_help + exit 0 + end +end.order! + +command = ARGV.shift + +if command.nil? + print_help + exit 0 +end + +unless SUBCOMMANDS.key?(command) + puts "Unknown command: #{command}" + print_help + abort +end + +# --- Parse command-specific options --- +cmd_options = {} + +case command +when "authorize" + OptionParser.new do |opts| + opts.banner = "Usage: bin/google-oauth authorize --project PROJECT [--scopes SCOPES]" + + opts.on("-p", "--project PROJECT", "Project name (e.g. MYAPP)") do |v| + cmd_options[:project] = v + end + + opts.on("-s", "--scopes SCOPES", "Comma-separated scope aliases (optional - will prompt if not provided)") do |v| + cmd_options[:scopes] = v + end + end.order!(ARGV) + +when "status" + OptionParser.new do |opts| + opts.banner = "Usage: bin/google-oauth status --project PROJECT" + + opts.on("-p", "--project PROJECT", "Project name (e.g. MYAPP)") do |v| + cmd_options[:project] = v + end + end.order!(ARGV) +end + +# --- Dispatch --- +case command +when "authorize" + cmd_authorize(cmd_options) +when "status" + cmd_status(cmd_options) +when "scopes" + print_scopes +else + abort "Unknown command: #{command}" +end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 6fbdedd0..431a1cf3 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -17,4 +17,5 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym "IO" + inflect.acronym "OAuth2" end diff --git a/docs/todo/plan-gmail-output.md b/docs/todo/plan-gmail-output.md index 180944c0..73f796d1 100644 --- a/docs/todo/plan-gmail-output.md +++ b/docs/todo/plan-gmail-output.md @@ -138,9 +138,9 @@ ctx.client.gmail(credentials_env: "...", mode: "real").deliver(...) ```ruby ctx.client.gmail( - credentials_env: "GOOGLE_CREDENTIALS_PXOPULSE" + credentials_env: "GOOGLE_CREDENTIALS_MYAPP" ).deliver( - to: "pxopulse@gmail.com", + to: "recipient@example.com", subject: "Weekly pulse", body: formatted_content ) diff --git a/docs/todo/plan-google-sheets-client.md b/docs/todo/plan-google-sheets-client.md index 90c23fa0..ffa9f551 100644 --- a/docs/todo/plan-google-sheets-client.md +++ b/docs/todo/plan-google-sheets-client.md @@ -120,7 +120,7 @@ end ```ruby ctx.client.google_sheets( spreadsheet_id: "13T1oLQXmhbBYMe0shLs-5aJJsW5Esgx9xaNjSMclubU", - credentials_env: "GOOGLE_CREDENTIALS_PXOPULSE" + credentials_env: "GOOGLE_CREDENTIALS_MYAPP" ).read_rows(range: "ThisWeekApproved") # Returns: From f831abbfc5fad059f845f8e8eb4108f41ad698ec Mon Sep 17 00:00:00 2001 From: zewelor Date: Sat, 21 Mar 2026 17:03:24 +0000 Subject: [PATCH 2/2] Implement Google OAuth2 - Remove docs/todo/plan-google-oauth2.md - This file outlined an interactive CLI and Google auth client - Remove stale TODO now tracked elsewhere or implemented - Reduce docs clutter and maintenance overhead --- docs/todo/plan-gmail-output.md | 35 ------ docs/todo/plan-google-oauth2.md | 154 ------------------------- docs/todo/plan-google-sheets-client.md | 12 -- 3 files changed, 201 deletions(-) delete mode 100644 docs/todo/plan-google-oauth2.md diff --git a/docs/todo/plan-gmail-output.md b/docs/todo/plan-gmail-output.md index 73f796d1..9a4b285e 100644 --- a/docs/todo/plan-gmail-output.md +++ b/docs/todo/plan-gmail-output.md @@ -154,41 +154,6 @@ ctx.client.gmail( --- -## PxoWeekly email format - -Based on n8n workflow output: - -``` -🇬🇧🎉 This week in Porto Santo - -🌅 #Sunsessions with M da Silva -📍 Foot On Water Restaurant & Beach Bar -📅 17 de agosto, 16h30–20h30 - -🎤 Concert at Praça -📍 Town Square -📅 18 de agosto, 21h00 - -💬 More details on pxopulse.com - - -============== - -🇵🇹🎉 Esta semana em Porto Santo - -🌅 #Sunsessions com M da Silva -📍 Foot On Water Restaurant & Beach Bar -📅 17 de agosto, 16h30–20h30 - -🎤 Concerto na Praça -📍 Praça do Povo -📅 18 de agosto, 21h00 - -💬 Mais detalhes em pxopulse.com -``` - ---- - ## Comparison with Discord Output | Aspect | `Outputs::Discord` | `Outputs::Gmail` | diff --git a/docs/todo/plan-google-oauth2.md b/docs/todo/plan-google-oauth2.md deleted file mode 100644 index 93d57eb6..00000000 --- a/docs/todo/plan-google-oauth2.md +++ /dev/null @@ -1,154 +0,0 @@ -# Plan: Google OAuth2 Helper - -## Overview - -Interactive CLI helper for obtaining Google refresh tokens and shared OAuth2 credential module -for per-project Google API credentials stored in Vault. - -## Files - -1. `app/lib/r3x/client/google_auth.rb` — shared credential builder -2. `bin/google-oauth` — interactive CLI helper - ---- - -## 1. `app/lib/r3x/client/google_auth.rb` - -Shared module for building OAuth2 credentials from JSON. - -```ruby -module R3x::Client::GoogleAuth - SCOPE_ALIASES = { - sheets: Google::Apis::SheetsV4::AUTH_SPREADSHEETS_READONLY, - gmail: Google::Apis::GmailV1::AUTH_GMAIL_SEND - }.freeze - - def self.from_json(parsed_json, scope:) - Signet::OAuth2::Client.new( - client_id: parsed_json.fetch("client_id"), - client_secret: parsed_json.fetch("client_secret"), - refresh_token: parsed_json.fetch("refresh_token"), - token_credential_uri: "https://oauth2.googleapis.com/token", - scope: Array(scope) - ).tap(&:fetch_access_token!) - end -end -``` - -**Error handling:** -- `KeyError` if required fields missing from JSON -- Propagate `Signet::AuthorizationError` on invalid refresh token - ---- - -## 2. `bin/google-oauth` - -```bash -#!/usr/bin/env ruby -require_relative "../config/environment" -require "optparse" -``` - -### Commands - -| Command | Description | -|---------|-------------| -| `authorize --project PROJECT --scopes sheets,gmail` | Start OAuth2 flow | -| `status --project PROJECT` | Check credential status | - -### Pre-setup (manual) - -1. Google Cloud Console → create project -2. Enable APIs: Sheets API + Gmail API -3. OAuth consent screen → External → Add test users -4. Credentials → Create OAuth client ID → Desktop app -5. Extract `client_id` and `client_secret` -6. Store in Vault: `GOOGLE_CLIENT_ID_`, `GOOGLE_CLIENT_SECRET_` - -### `authorize` flow - -1. Read `GOOGLE_CLIENT_ID_` and `GOOGLE_CLIENT_SECRET_` from ENV -2. Map scope aliases to Google OAuth scopes -3. Build auth URL with `Signet::OAuth2::Client` (redirect_uri: `urn:ietf:wg:oauth:2.0:oob`) -4. Print URL to console -5. Prompt user to paste authorization code -6. Exchange code for tokens -7. Output JSON to console: - ```json - {"client_id":"...","client_secret":"...","refresh_token":"..."} - ``` -8. Instruct user to store in Vault as `GOOGLE_CREDENTIALS_` - -### `status` flow - -1. Check if `GOOGLE_CREDENTIALS_` exists in ENV -2. Try to fetch access token (validates refresh token) -3. Report: credentials present, token valid/invalid - -### Scope aliases - -| Alias | Google scope | -|-------|-------------| -| `sheets` | `https://www.googleapis.com/auth/spreadsheets.readonly` | -| `gmail` | `https://www.googleapis.com/auth/gmail.send` | - ---- - -## Env Vars - -**Pre-OAuth (temporary, from Google Cloud Console):** -- `GOOGLE_CLIENT_ID_PXOPULSE` -- `GOOGLE_CLIENT_SECRET_PXOPULSE` - -**Post-OAuth (permanent, from `bin/google-oauth authorize`):** -- `GOOGLE_CREDENTIALS_PXOPULSE` — JSON with client_id, client_secret, refresh_token - -All loaded from Vault via `R3x::Env.load_from_vault` at boot. - ---- - -## Vault structure - -``` -secret/data/env/r3x - ├── GOOGLE_CLIENT_ID_PXOPULSE - ├── GOOGLE_CLIENT_SECRET_PXOPULSE - ├── GOOGLE_CREDENTIALS_PXOPULSE - └── GOOGLE_CREDENTIALS_OTHERPROJECT -``` - ---- - -## Per-project pattern - -Follows same naming as LLM API keys (`GEMINI_API_KEY_MICHAL`). - -Workflow specifies which credentials to use: -```ruby -ctx.client.google_sheets( - spreadsheet_id: "...", - credentials_env: "GOOGLE_CREDENTIALS_PXOPULSE" -) -``` - -Validation via `secure_fetch`: -```ruby -R3x::Env.secure_fetch(credentials_env, prefix: "GOOGLE_CREDENTIALS_") -``` - ---- - -## Dependencies - -- `googleauth` gem (already in Gemfile) -- `signet` (comes with googleauth) -- `multi_json` (already in Gemfile) - ---- - -## Related files - -- `lib/r3x/env.rb` — `secure_fetch`, `load_from_vault` -- `config/initializers/r3x_vault_env.rb` — boot-time Vault loading -- `docs/todo/plan-google-sheets-client.md` -- `docs/todo/plan-gmail-output.md` diff --git a/docs/todo/plan-google-sheets-client.md b/docs/todo/plan-google-sheets-client.md index ffa9f551..5558a6b1 100644 --- a/docs/todo/plan-google-sheets-client.md +++ b/docs/todo/plan-google-sheets-client.md @@ -132,18 +132,6 @@ ctx.client.google_sheets( --- -## PxoWeekly sheet columns - -Based on n8n workflow, `ThisWeekApproved` has: -- `name` — event name -- `start_date` — format `d/M/yyyy` -- `end_date` — optional -- `start_time` — time string -- `location` — venue -- `category` — event category - ---- - ## Sharing requirement The Google Sheet must be shared with the OAuth account's email address.