SEO Readiness Portal

Documentation for ALAI SEO Readiness Portal MVP — client audit workflows, GBP local-first mode, reporting.

GBP/Local-First Audit Mode (No-Website Clients)

GBP/Local-First Audit Mode (No-Website Clients)

Implemented: MC #102861 (2026-06-03)
Driver: First real client Smoke House Hadžići (no website, GBP-only) via partner Asmir
Status: PASS — type-check, build, validate:phase4, validate:phase5, validate:gbp-local

Overview

The SEO Readiness Portal now supports clients who do not have a website and rely solely on Google Business Profile (GBP) as their primary online presence. This is a common scenario for small local businesses (restaurants, trades, services).

When a client is detected as no-website, the audit runner switches into GBP-first mode and runs a different checklist tailored to local optimization best practices.

Detection Logic

No-website mode is activated when:

The detection is implemented in src/lib/audit/runner.ts function isNoWebsiteMode(site, intake).

New Intake Fields (Optional, Non-Breaking)

Three new fields have been added to the Intake type (src/lib/workspace/types.ts):

Field Type Purpose
hasWebsite boolean? Explicit override: when false, forces no-website mode even if canonicalUrl is not empty
gbpClaimed boolean? Whether the business owner has claimed the GBP listing
gbpManagerAccess boolean? Whether ALAI/partner has been added as a manager to the GBP listing

All three are optional and undefined-safe. Existing fixtures and validations are unaffected.

GBP-First Checklist (12 Checks)

When no-website mode is active, the audit runs the following checks instead of website-dependent checks:

Check ID Severity Category Description
intake-not-submitted P1 evidence Intake must be submitted or reviewed before workspace is review-ready
gbp-first-strategy P1 local Strategy finding: recommends GBP-first approach + optional simple-site later
gbp-not-claimed P0 local Business owner has not claimed the GBP listing → cannot manage or optimize
gbp-manager-access-missing P1 local ALAI/partner not added as manager → cannot perform optimization work
gbp-claim-status-unknown P1 local Claim/manager status not confirmed → action recommended to clarify
gbp-nap-url-missing P1 local GBP URL not recorded → NAP consistency cannot be verified
gbp-primary-category-missing P2 local Priority services missing → cannot determine correct GBP primary category
gbp-hours-not-confirmed P2 local Business hours not confirmed → can reduce customer trust
gbp-photos-not-confirmed P2 local Photos not confirmed → profiles with photos attract more engagement
gbp-review-routine-not-confirmed P2 local Review response routine not set → important trust/engagement signal
gbp-posts-not-confirmed P2 local GBP posts activity not confirmed → posts keep profile active and relevant
missing-priority-services P2 content Priority services missing → needed for page/backlog recommendations
missing-competitors P2 content Competitor list missing → needed for manual review context
access-status-unknown P2 evidence Analytics/Search Console access status unknown

Total: 12 checks in no-website mode vs. 6 in standard website mode.

Skipped Website-Dependent Checks

The following checks are conditionally skipped in no-website mode:

These are replaced by the GBP-specific checks above.

Report/Backlog/Export Flow

GBP-first findings flow through the existing report/backlog/export workflow with no changes:

Validation Scripts

A new fixture script validates the no-website path:

npm run validate:gbp-local

This runs scripts/validate-gbp-local.ts which:

Existing validations remain unchanged and passing:

GBP API Integration (Deferred)

Important operator note: This is a manual-input MVP. The portal does not integrate with the Google Business Profile API to fetch live data (photos count, reviews count, hours, etc.).

All GBP checks rely on:

Live GBP API integration is a follow-on scope, likely in parallel with Google Search Console/Analytics integration (MC #102806 deferred).

Safe Defaults

All GBP checks follow a fail-closed philosophy:

Example: if gbpClaimed is undefined, the check emits gbp-claim-status-unknown (P1) asking the operator to confirm with the client.

Implementation Evidence

Summary

The SEO Readiness Portal now handles two client profiles:

  1. Website clients (existing path) — 6 checks, website-dependent validation
  2. No-website / GBP-only clients (new path) — 12 checks, GBP-first optimization

Detection is automatic based on canonicalUrl or explicit hasWebsite flag. Both paths use the same report/backlog/export workflow. No breaking changes to existing fixtures or validations.

SEO Client Workflow — Skill (/seo-client) + Intake Auto-Ingest

SEO Client Workflow — Layers 2+3 Architecture

Status: Active (2026-06-03)
Driver: Asmir SEO partner clients (first = Smoke House Hadžići)
MCs: #102865 (skill), #102866 (intake ingest)
Owner: Skillforge (docs), CodeCraft (ingest), FlowForge (launchd)

Architecture Overview

The SEO client workflow is structured in 3 layers:

Layer 2: /seo-client Skill

Location

~/.claude/skills/seo-client/SKILL.md

Purpose

Given a client SEO intake (from SEO Readiness Portal workspace data OR pasted partner email), produce 5 review-ready DRAFT deliverables in Bosnian. Never auto-send. All outputs are local drafts under /tmp/alai/seo-client/<client-slug>/ for human review before delivery.

Inputs

Deliverables (5 Bosnian DRAFTS)

  1. gbp-content.md — GBP optimization content:
    • Business description (max 750 chars, keyword-rich, natural BS)
    • Primary + 2-3 secondary categories (from GBP taxonomy)
    • Suggested attributes (wheelchair accessible, parking, etc.)
    • 3-5 sample GBP posts (seasonal offers, service updates, local events)
    • Review-ask template (polite, brief, BS)
  2. competitor-benchmark.md — Competitor analysis:
    • For each competitor (up to 3): GBP completeness, review count/rating, categories, notable strengths
    • Client gap analysis + quick wins
    • Actionable recommendations
    • Note: No live GBP API scraping (Phase 10+); manual research or intake-provided competitor URLs
  3. client-report.md — Client-facing SEO readiness report (BS):
    • Executive summary (current status + top opportunity)
    • Goals (from intake: target location, priority services, conversion action)
    • Current status table (Technical SEO, On-page, Content, Local SEO, Conversion — RAG status)
    • Top findings (P0/P1/P2 prioritized)
    • Recommended action plan (Week 1-2, Month 1, Month 2-3)
    • What we need from client (access, approvals, content)
    • Measurement (KPIs: organic traffic, GBP interactions, contact forms)
    • Disclaimer: SEO does NOT guarantee ranking/traffic
    • Evidence grounding: portal audit findings (if website exists) or GBP checklist (if GBP-first mode)
  4. client-email-draft.md — Draft client email (BS):
    • Subject line suggestion
    • Summary: what was reviewed, top findings
    • Immediate asks: missing access, info gaps, priorities
    • Next steps: what ALAI/partner will do after client responds
    • Disclaimer: readiness review only, no ranking/traffic guarantee
    • Mark as DRAFT — never auto-send
  5. owner-gbp-claim-guide.md — Step-by-step GBP claim + add-ALAI-as-Manager guide (BS):
    • Go to business.google.com
    • Search for business name + location
    • Claim existing listing (if unclaimed)
    • Verify ownership (postcard/phone/email)
    • Complete profile: hours, photos, services, description
    • Add ALAI/partner as Manager (Settings → Users → Add user, role: Manager)
    • Notify ALAI/partner when access granted
    • Screenshot placeholders: [Screenshot: GBP claim button]

Guardrails

Workflow Modes

Evidence Bundle

On completion, creates /tmp/alai/seo-client/<client-slug>/DELIVERABLES-MANIFEST.md with:

Registration

Registered in ~/.claude/skill-registry.db:
- Name: seo-client
- Triggers: /seo-client, "seo client workflow", "process seo intake", "generate seo deliverables", "asmir seo tip"
- Domain: seo-operations
- Level: 3
- Max Turns: 30
- Status: active

Layer 3: Intake Auto-Ingest

Location

~/business/ALAI-Holding-AS/products/SEO-Readiness-Portal/scripts/ingest-intake-email.ts

Purpose

Parse inbound client SEO intake email (numbered questionnaire format) and append a DRAFT Client+Site+Intake record to the portal workspace. Idempotent via ingest ledger. Read-only email access; no email send.

Usage

# Ingest specific email by ID
npm run ingest:intake -- --email-id <id>

# Scan pending ACTION emails (list only)
npm run ingest:intake -- --scan

# Dry-run mode (no writes)
npm run ingest:intake -- --email-id <id> --dry-run

# Auto-run audit after ingest (deferred; not implemented in current draft)
npm run ingest:intake -- --email-id <id> --run-audit

Workflow

  1. Email fetch: Shell-out to node ~/system/tools/email-inbox.js show <id> (read-only, no network calls)
  2. Parse: Extract numbered intake fields:
    • Business name, location, services, languages
    • Target markets (local/regional/national)
    • Competitors (names or URLs)
    • GBP status (claimed/unclaimed, manager access yes/no)
    • Website status (has website yes/no, URL if yes)
    • Analytics/Search Console status
    • Priority services, conversion goals
  3. Workspace append: Create draft Client, Site, Intake objects:
    • client.status = "lead"
    • intake.status = "submitted"
    • intake.hasWebsite = inferred from question "Do you have a website?"
    • site.canonicalUrl = empty if no website (triggers GBP-first mode in skill)
  4. Idempotency: Dedup by source email ID via .data/ingest-ledger.json:
    • Schema: { schemaVersion: 1, updatedAtUtc, ingestedEmailIds: { [emailId]: { emailId, clientId, siteId, intakeId, ingestedAtUtc } } }
    • If email ID already in ledger → skip (NOOP)
  5. Output: JSON summary to stdout:
    • Email ID, client ID, site ID, intake ID
    • Client name, status, target markets
    • Site domain (or "no website — GBP/local mode")
    • Intake status, hasWebsite, gbpClaimed, gbpManagerAccess, priorityServicesCount, competitorsCount

Guardrails

Launchd Wrapper (Deferred)

Template at scripts/launchd/com.alai.seo-intake-ingest.plist:
- NOT installed (manual trigger for now)
- When enabled: periodic scan for ACTION emails, auto-ingest eligible intake questionnaires
- Safe + logged (writes to ~/logs/seo-intake-ingest.log)

End-to-End Flow

  1. Intake email arrives (from Asmir or direct client) → tagged ACTION by email-inbox.js rules
  2. Layer 3 ingest: npm run ingest:intake -- --email-id <id> → creates draft client in portal workspace
  3. Portal GBP audit: CEO or automated task runs audit (Layer 1) → evidence collected
  4. Layer 2 skill: /seo-client generates 5 Bosnian DRAFT deliverables
  5. CEO review: human reviews drafts at /tmp/alai/seo-client/<client-slug>/
  6. Send: CEO or agent manually copies content to client email / BookStack client page

Limitations / Deferred

Evidence

SEO Portal intake-audit loop + chatbot P0 security (MC 103100/103105/103101) 2026-06-07

SEO Portal: intake-audit loop + chatbot P0 security

Date: 2026-06-07 | MCs: #103100 (intake-audit loop), #103105 (chatbot P0 security), #103101 (chatbot eval) | Deployed image: alairegistry.azurecr.io/seo-readiness-portal:20260607-intake-audit-p0 | Source commit: c6aed80ab (branch seo-intake-audit-loop-103100)

What changed

#103100 — intake-audit loop (gaps B + C)

#103105 — chatbot P0 security fixes (from #103101 eval)

Verification

Known follow-ups

SEO Portal Path C — Delegated Service-Account GSC/GA via Workload Identity Federation (MC #103390)

Overview

MC #103390 — SEO Readiness Portal Path C replaces the blocked per-client Google OAuth flow with a delegated read-only service account authenticated via Workload Identity Federation (WIF). No static JSON key is created or stored; the portal authenticates entirely through Azure App Service's managed identity federating into GCP.

Why Path C?

The previous OAuth path required Google to verify the app's OAuth consent screen for restricted scopes (webmasters.readonly + analytics.readonly). Asmir (pilot client, snowit.ba) hit the hard block: "Access blocked: snowit.ba has not completed the Google verification process" on /api/oauth/google/start. Google app verification for restricted scopes takes weeks and requires domain validation, brand approval, and security review — not feasible for an MVP launch.

Path C sidesteps this entirely: the portal reads GSC and GA4 data as a single read-only service account that each client explicitly grants access to in their own Google properties. No OAuth consent screen, no Google app verification, no per-user token storage.

Service Account

FieldValue
SA email (canonical)seo-gsc-reader@tribal-sign-487920-k0.iam.gserviceaccount.com
GCP projecttribal-sign-487920-k0 (project number: 762788903040)
Display nameSEO Portal GSC/GA read-only reader
Unique ID103977132559951298607
Project IAM rolesNone — access granted per-property by each client only
APIs enabledsearchconsole.googleapis.com, analyticsdata.googleapis.com, iamcredentials.googleapis.com, sts.googleapis.com

Workload Identity Federation Chain

Because the GCP organisation (ID: 897698145517) enforces the org policy constraints/iam.disableServiceAccountKeyCreation, no static JSON key can be created. WIF allows the Azure App Service's managed identity to impersonate the SA at runtime without any long-lived credential.

Architecture Diagram

┌────────────────────────────────────────────────────────────────────────────────┐
│                        SEO Portal — Path C Auth Chain                          │
│                                                                                │
│  Azure App Service (seo-readiness-alai, rg-seo-readiness-prod)                │
│  System-Assigned Managed Identity                                              │
│    principalId (objectId): b381e292-c4ce-488f-a553-3da32d077461               │
│    tenantId:               3454a03f-20b4-4bda-a116-2293c459aecd               │
│         │                                                                      │
│         │ 1. Fetch MI token from Azure IMDS                                    │
│         │    GET http://169.254.169.254/metadata/identity/oauth2/token         │
│         │        ?resource=api://AzureADTokenExchange                          │
│         ▼                                                                      │
│  Azure AD (tenant 3454a03f)                                                    │
│    OIDC issuer: https://sts.windows.net/3454a03f.../                           │
│         │                                                                      │
│         │ 2. Exchange MI JWT → GCP federated token                             │
│         │    POST https://sts.googleapis.com/v1/token                          │
│         ▼                                                                      │
│  GCP WIF Pool: azure-mi-pool                                                   │
│    Resource: projects/762788903040/locations/global/                           │
│              workloadIdentityPools/azure-mi-pool                               │
│    Provider: azure-ad-mi                                                       │
│      - OIDC issuer: sts.windows.net/3454a03f.../                               │
│      - Audience:    api://AzureADTokenExchange                                 │
│      - Attribute condition (strict lock):                                      │
│          assertion.sub == "b381e292-c4ce-488f-a553-3da32d077461"               │
│          && assertion.tid == "3454a03f-20b4-4bda-a116-2293c459aecd"            │
│         │                                                                      │
│         │ 3. Impersonate SA via iamcredentials.googleapis.com                  │
│         │    roles/iam.workloadIdentityUser granted to this MI principal       │
│         ▼                                                                      │
│  Service Account: seo-gsc-reader@tribal-sign-487920-k0.iam.gserviceaccount.com │
│    Short-lived access token (1h TTL, auto-refreshed)                           │
│         │                                                                      │
│         │ 4a. GSC Search Analytics API                                         │
│         │     searchconsole.searchanalytics.query(siteUrl)                     │
│         │ 4b. GA4 Data API                                                     │
│         │     analyticsdata.runReport(properties/NNNN)                        │
│         ▼                                                                      │
│  Client's GSC + GA4 property (client must grant SA email as Viewer/Restricted) │
└────────────────────────────────────────────────────────────────────────────────┘

WIF Resource Names

ResourceName / Value
WIF Pool IDazure-mi-pool
WIF Pool resourceprojects/762788903040/locations/global/workloadIdentityPools/azure-mi-pool
WIF Provider IDazure-ad-mi
WIF Provider resourceprojects/762788903040/locations/global/workloadIdentityPools/azure-mi-pool/providers/azure-ad-mi
OIDC issuerhttps://sts.windows.net/3454a03f-20b4-4bda-a116-2293c459aecd/
Allowed audienceapi://AzureADTokenExchange
Attribute conditionassertion.sub == "b381e292-..." && assertion.tid == "3454a03f-..."
SA impersonation memberprincipal://iam.googleapis.com/projects/762788903040/locations/global/workloadIdentityPools/azure-mi-pool/subject/b381e292-c4ce-488f-a553-3da32d077461

WIF Credential Config (non-secret)

Stored as Azure App Service app setting GOOGLE_APPLICATION_CREDENTIALS_JSON on seo-readiness-alai. This JSON contains no private key material — it is safe to log at the config level (but not to expose to browsers). Local copy: /tmp/evidence-103390/wif-credential-config.json.

{
  "type": "external_account",
  "audience": "//iam.googleapis.com/projects/762788903040/locations/global/workloadIdentityPools/azure-mi-pool/providers/azure-ad-mi",
  "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
  "token_url": "https://sts.googleapis.com/v1/token",
  "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/seo-gsc-reader@tribal-sign-487920-k0.iam.gserviceaccount.com:generateAccessToken",
  "credential_source": {
    "url": "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=api%3A%2F%2FAzureADTokenExchange",
    "headers": { "Metadata": "true" },
    "format": { "type": "json", "subject_token_field_name": "access_token" }
  }
}

Connection-Verify Flow (verifyAndSaveGrant)

The server action verifyAndSaveGrant() in src/app/intake/[token]/access-grant/actions.ts handles the client grant confirmation:

  1. Token gate: clientId is extracted from the HMAC intake token payload only — never from user input.
  2. Field validation: GSC requires siteUrl; GA4 requires ga4PropertyId.
  3. Live probe: Calls verifyAccess() from sa-reader.ts, which attempts one real API call using the WIF-authenticated SA. A 403 from Google means the client has not yet added the SA as a user.
  4. Persist on success: Calls saveGoogleConnectionSa() — stores connectionMode="delegated_sa", sentinel token values, grantVerified=true, verifiedAt=now. Idempotent on platform.
  5. Error isolation: not_granted → nothing persisted, SA email returned for display. api_error / misconfigured → generic error, nothing persisted. Token value never included in any returned detail.

Security Posture

PropertyDetails
No static keyOrg policy constraints/iam.disableServiceAccountKeyCreation is respected. No JSON key was created or stored anywhere.
Read-only SASA holds no project IAM roles. Access is granted only per-property by the client in their own GSC/GA4 settings.
Strict WIF attribute conditionOnly the specific Azure MI (objectId b381e292) from the specific tenant (3454a03f) can impersonate the SA. No other identity can use this WIF provider.
Short-lived tokensWIF-generated access tokens have a 1-hour TTL and are fetched at runtime by the google-auth-library ADC. No long-lived credential on disk.
clientId isolationverifyAndSaveGrant() derives clientId from HMAC-signed token only. A client cannot probe another client's property.
No secret loggingVerified via grep in ST2 evidence: no console calls, no token/auth/credential values logged anywhere in sa-reader.ts.
ScopeReads are scoped to GSC Search Analytics and GA4 Data API. No write scopes, no Admin SDK, no domain-wide delegation.

No-Key Rotation / How WIF Differs from Key Rotation

Traditional SA Key Rotation

With a static JSON key (GOOGLE_SA_KEY), the operational runbook is:

  1. Generate a new key via GCP IAM Console.
  2. Update the Azure App Service secret GOOGLE_SA_KEY.
  3. Restart the App Service to pick up the new value.
  4. Verify the old key is revoked in GCP IAM → Service Accounts → Keys.
  5. Repeat every 90 days (or on suspected compromise).

This path is not available because constraints/iam.disableServiceAccountKeyCreation is enforced at org level (org ID: 897698145517). No account with org-level orgpolicy.policyAdmin is currently credentialed.

WIF — There Is Nothing to Rotate

The WIF credential config (GOOGLE_APPLICATION_CREDENTIALS_JSON) contains no private key material. It is a routing config that tells the google-auth-library how to reach the Azure IMDS endpoint and which WIF pool to exchange against. It is not a secret. Tokens are minted at runtime by Azure MI (renewed automatically by the platform) and exchanged for short-lived GCP access tokens.

Operational actions that DO require maintenance:

Summary: WIF eliminates the 90-day key rotation burden and removes the attack surface of a long-lived credential leaking from a secrets store. The tradeoff is that the WIF configuration binds to infrastructure identities (Azure MI objectId) rather than a portable key — so infra changes require config updates, not just secret rotation.

Evidence Files

Client Onboarding Summary

Each client must add the SA email as a user in their own Google properties before the portal can read data. The steps are documented in the Bosnian client-facing document (file path above, Lexicon validation pending). In brief:

  1. Google Search Console: Settings → Users and permissions → Add user → paste seo-gsc-reader@tribal-sign-487920-k0.iam.gserviceaccount.com → permission Restricted or Full → Add.
  2. Google Analytics 4: Admin → Property Access Management → + → paste same email → role Viewer → Add.
  3. Portal grant page: Paste GSC site URL + GA4 property ID → click Verify. The verifyAndSaveGrant() action probes the live APIs and persists the connection on success.

Open Items / Follow-On

SEO Pipeline — Portal Intake → John Deep-Report (agent runbook)

Purpose

The SnowIT SEO offering uses the portal for intake and crawl at scale (zero partner effort). John (agents) produces the quality deep report. The portal’s own auto-report is a shallow teaser; the real deliverable is John’s deep-report play. This runbook is the canonical reference for any agent session picking up SEO work.


Architecture / Flow

Asmir (partner) adds client via /partners in portal
  OR John mints magic-link directly
        |
        v
System emails HMAC-bound magic-link to end client
        |
        v
Client opens https://seo-tools.snowit.ba/intake/<token>
  fills smart form + AI chatbot
        |
        v
Portal auto-crawls site
  stores intakeSubmission + crawl summary in workspace.json
  fires notify_audit_ready webhook/email
        |
        v
seo-intake-watcher (LaunchAgent) polls workspace every 10 min
  detects NEW submission
  emails john@alai.no
        |
        v
John agent session runs ~/system/prompts/seo-deep-report-play.md
        |
        v
Quality deep report delivered to Asmir / client

Key Live Facts (verified 2026-06-22)

ItemValue
Portal appAzure App Service seo-readiness-alai, RG rg-seo-readiness-prod
Current known-good image20260622-audit-depth (v1.1.0 crawl audit engine)
Public URLsseo-tools.snowit.ba / seo-tools.alai.no
Deploy methodaz acr build + az webapp config container set (DEPLOY-MAP.md in repo root; ZAKON PI2)
Workspace read (read-only)Kudu VFS + AAD token: az account get-access-token --resource https://management.azure.com then GET https://seo-readiness-alai.scm.azurewebsites.net/api/vfs/data/workspace.json
Handoff watcher script~/system/tools/seo-intake-watcher.js
LaunchAgentcom.john.seo-intake-watcher (10-min poll; emails john@alai.no on NEW intake only)
Watcher state~/system/state/seo-intake-watcher-seen.json
Deep-report play~/system/prompts/seo-deep-report-play.md
GSC/GA layerOPTIONAL / separate — pending OAuth verification (MC #103428). Crawl-based audit needs NO Google credentials.

Trigger Checklist — Receiving a New Intake

  1. Email arrives at john@alai.no with subject containing "SEO intake" or "audit_ready".
  2. Open workspace.json via Kudu VFS (read-only; see table above) and confirm intakeSubmission is present for the client domain.
  3. Read ~/system/prompts/seo-deep-report-play.md — follow it exactly. Do not improvise the report structure.
  4. Deliver completed report to Asmir (partner) and optionally to client email on record.
  5. Mark intake as processed in seen-state: add domain + timestamp to ~/system/state/seo-intake-watcher-seen.json.

When to Use the Portal vs Do It Directly

For a small number of one-off clients, John running the audit directly (without the portal intake flow) is faster and produces a deeper initial result. The portal earns its keep at intake volume — when Asmir is referring multiple clients per week and cannot afford to coordinate each one manually. (CEO note 2026-06-22.)


Anti-Pitfalls for Agents


Evidence & Memory Links