Skip to content

Instagram production readiness — IBlobStorageService + IgSender activation #72

Description

@artcava

This issue is deferred. It will not be scheduled until:


Context

IgSender implements the Instagram Graph API two-step flow (container creation + media publish),
but UploadImageToPublicUrl throws NotImplementedException.
Instagram is not included in any active slot in DefaultSlotProfileProvider and must be added only after the sender is verified in staging.

Known gaps identified by static analysis (June 2026)

# Gap Severity Notes
1 UploadImageToPublicUrl throws NotImplementedException 🔴 Critical Every SendAsync call fails silently
2 No JPEG format validation 🟠 High Instagram Graph API only accepts JPEG; PNG/SVG/MPO/JPS are rejected
3 access_token sent in JSON body 🟠 High Meta best practice: pass as query parameter, not in body
4 Raw API response logged on error 🟠 High Response body may echo the token — violates "no sensitive token logging" rule
5 No rate-limit (429) handling 🟡 Medium API enforces 50 posts/24h; Polly pipeline must handle retry-after
6 No container_status polling before publish 🟡 Medium Required: verify container is FINISHED before calling /media_publish — implemented via XPosterContainerPollingFunction, not inline in IgSender
7 Token expiry not managed 🟡 Medium Resolved — a long-lived non-expiring token is already in production. No refresh flow needed. docs/integrations/SenderPlugins/setup-instagram.md must be updated to remove the reference to automated refresh tracked in this issue.

Architecture Constraints (from .net-architect)

  • IBlobStorageService must be injected via constructor — no direct BlobServiceClient instantiation inside IgSender.
  • Azure Blob configuration must be bound via IOptions registered in Program.cs, not read via Environment.GetEnvironmentVariable inline.
  • BlobServiceClient should be registered as a singleton in the DI container (use Azure.Storage.Blobs native DI extension or manual singleton registration).
  • Sender boundary must be preserved: IgSender calls IBlobStorageService.UploadAsync, it does not own storage lifecycle.
  • IgSender must not poll container_status inline — it saves state and returns. Polling is delegated to XPosterContainerPollingFunction.
  • Slot activation (DefaultSlotProfileProvider) is a separate, final step — gated on staging validation.

Two-Phase Async Architecture

Instagram publishing requires an asynchronous two-phase flow due to Meta's media processing pipeline. Implementing polling inline in IgSender.SendAsync would risk Function timeout and block the main timer trigger. The solution uses two independent Azure Functions within the same Function App.

Phase 1 — XFunction (existing, extended)

Timer fires → XFunction → IgSender.SendAsync()
  ├── Upload image → IBlobStorageService.UploadAsync() → SAS URL (30 min, read-only)
  ├── POST /media → Meta Graph API → creation_id
  ├── IContainerStateStore.SaveAsync(creation_id, blobName, Pending)
  └── return true  ← does NOT block, no timeout risk

Phase 2 — XPosterContainerPollingFunction (new)

Timer fires (~every 2 min) → XPosterContainerPollingFunction
  ├── IContainerStateStore.GetPendingAsync() → [creation_id, blobName]
  └── For each pending:
        GET /{creation_id}?fields=status_code → Meta
        ├── FINISHED   → POST /media_publish → DeleteBlob → UpdateStatus(Published)
        ├── IN_PROGRESS → skip, next timer round
        └── ERROR/EXPIRED → UpdateStatus(Failed) + log + DeleteBlob

Why not Azure Durable Functions

Durable Functions require a dedicated Storage Account for orchestration state (tables + queues), introduce non-linear pricing at scale, and add significant architectural complexity. For XPoster's current and near-term volume, the cost/benefit ratio is unfavorable. A second Timer Trigger is sufficient and fully consistent with the existing architecture pattern in XFunction.cs.

Cost impact of the new TimerTrigger (every 2 min)

Metric Value
Executions/month ~21,900
Azure Functions free grant 1,000,000 exec + 400,000 GB-s/month
Total subscription executions (incl. XPosterFunction) ~22,620
% of free grant used 2.3%
Additional monthly cost € 0.00
Application Insights log volume ~11 MB/month (free grant: 5 GB)

No additional Azure cost is incurred. Log verbosity must be kept low: LogInformation only on actionable events (publish, fail); LogDebug for skip rounds.


Part 1 — Azure Blob Storage Setup

  1. Create/reuse Azure Storage Account in the same resource group as the Function App.
  2. Create container xposter-images with private access (no anonymous read).
  3. Add app settings: AZURE_STORAGE_CONNECTION_STRING, AZURE_STORAGE_CONTAINER_NAME.
  4. (Recommended for production) Use Managed Identity + DefaultAzureCredential with Storage Blob Data Contributor role.
  5. Add lifecycle rule to auto-delete blobs older than 1 day (safety net).

SAS URL policy: IBlobStorageService.UploadAsync must return a SAS URL with read-only permission and a validity of 30 minutes (start time set to UtcNow.AddMinutes(-5) to absorb clock skew between Azure and Meta servers). This is more secure than anonymous public access and fully compatible with Meta's media upload policy.

⚠️ Facebook Graph API (/photos endpoint) is stricter than Instagram on image format: only JPG, PNG, GIF, TIFF, HEIF, WebP are accepted. SVG and redirect URLs are rejected with error 1366046.


Part 2 — Code Implementation

2.1 NuGet

dotnet add src/XPoster.csproj package Azure.Storage.Blobs

2.2 New interface: IBlobStorageService

// XPoster.Contracts
public interface IBlobStorageService
{
    /// 
    /// Uploads raw bytes to blob storage and returns a time-limited SAS URL
    /// suitable for use as Meta media_url (direct GET, no auth headers, no redirects).
    /// 
    Task<Uri> UploadAsync(byte[] data, string contentType, CancellationToken cancellationToken = default);

    Task DeleteAsync(string blobName, CancellationToken cancellationToken = default);
}

2.3 New interface: IContainerStateStore

// XPoster.Contracts
public interface IContainerStateStore
{
    Task SaveAsync(string creationId, string blobName, CancellationToken cancellationToken = default);
    Task<IReadOnlyList<PendingContainer>> GetPendingAsync(CancellationToken cancellationToken = default);
    Task UpdateStatusAsync(string creationId, ContainerStatus status, CancellationToken cancellationToken = default);
}

public record PendingContainer(string CreationId, string BlobName);

public enum ContainerStatus { Pending, Published, Failed }

Initial implementation: InMemoryContainerStateStore in XPoster.Services. Suitable for staging and single-instance production (one post/day). Replace with Table Storage or Cosmos DB backing when multi-instance scale is required — no contract changes needed.

2.4 New interface: IMetaPublishingService

// XPoster.Contracts
public interface IMetaPublishingService
{
    Task<string> GetContainerStatusAsync(string creationId, CancellationToken cancellationToken = default);
    Task PublishContainerAsync(string creationId, CancellationToken cancellationToken = default);
}

Centralises Meta HTTP calls shared between IgSender and XPosterContainerPollingFunction. Uses the named "Instagram" HttpClient already registered via AddHttpClients().

2.5 Implementation: BlobStorageService

Implement in XPoster.Services, reading config from IOptions.
Return a SAS URL with BlobSasPermissions.Read, expiry UtcNow.AddMinutes(30), start UtcNow.AddMinutes(-5).
Log upload URI only — never log connection string or credentials.

2.6 Rename src/Credentials/IgCredentials.cssrc/Credentials/InstagramCredentials.cs

Follow the same pattern used for FacebookCredentials (see #224):

namespace XPoster.Credentials;

/// <summary>
/// Typed credentials for the Instagram sender.
/// Property names must match Azure Key Vault secret names exactly.
/// Bound via AddAzureKeyVault Configuration Provider and injected as IOptions{InstagramCredentials}.
/// </summary>
public sealed class InstagramCredentials
{
    /// Section name used to bind this class from configuration.
    public const string SectionName = "InstagramCredentials";

    /// Instagram Business Account numeric ID.
    public string InstagramAccountId { get; init; } = string.Empty;

    /// Long-lived Instagram Page Access Token (non-expiring).
    public string InstagramAccessToken { get; init; } = string.Empty;
}

The Key Vault secret names follow the {SectionName}{PropertyName} convention:

  • InstagramCredentialsInstagramAccountIdInstagramCredentials:InstagramAccountId
  • InstagramCredentialsInstagramAccessTokenInstagramCredentials:InstagramAccessToken

2.7 Create src/Credentials/InstagramCredentialsValidator.cs

using Microsoft.Extensions.Options;

namespace XPoster.Credentials;

public class InstagramCredentialsValidator : IValidateOptions<InstagramCredentials>
{
    public ValidateOptionsResult Validate(string? name, InstagramCredentials options)
    {
        if (string.IsNullOrWhiteSpace(options.InstagramAccountId))
            return ValidateOptionsResult.Fail("InstagramCredentials:InstagramAccountId is required.");
        if (string.IsNullOrWhiteSpace(options.InstagramAccessToken))
            return ValidateOptionsResult.Fail("InstagramCredentials:InstagramAccessToken is required.");
        return ValidateOptionsResult.Success;
    }
}

2.8 Create src/Credentials/InstagramCredentialsExtensions.cs

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace XPoster.Credentials;

public static class InstagramCredentialsExtensions
{
    public static IServiceCollection AddInstagramCredentials(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.Configure<InstagramCredentials>(configuration.GetSection(InstagramCredentials.SectionName));
        services.AddSingleton<IValidateOptions<InstagramCredentials>, InstagramCredentialsValidator>();
        return services;
    }
}

2.9 Register in Program.cs

// Azure Blob Storage
builder.Services.Configure<BlobStorageOptions>(builder.Configuration);
builder.Services.AddSingleton(sp =>
    new BlobServiceClient(builder.Configuration["AZURE_STORAGE_CONNECTION_STRING"]));
builder.Services.AddTransient<IBlobStorageService, BlobStorageService>();

// Instagram credentials
builder.Services.AddInstagramCredentials(builder.Configuration);

// Instagram async state
builder.Services.AddSingleton<IContainerStateStore, InMemoryContainerStateStore>();

// Meta publishing service (shared HTTP calls)
builder.Services.AddTransient<IMetaPublishingService, MetaPublishingService>();

Call only the extension method for credentials — never raw Configure(configuration.GetSection("...")) literals in Program.cs.

2.10 Update IgSender

  • Inject IBlobStorageService, IContainerStateStore, IMetaPublishingService, and IOptions<InstagramCredentials> via constructor.
  • Replace NotImplementedException in UploadImageToPublicUrl with await _blobStorageService.UploadAsync(image, "image/jpeg") returning the SAS Uri.
  • Validate JPEG format before upload: check magic bytes FF D8 and reject non-JPEG with LogWarning + return false.
  • Move access_token to query parameter: pass as ?access_token=... in the URL, never in JSON body.
  • Sanitize error logs: never log raw API response bodies; log only HTTP status code and a safe error summary.
  • Do NOT poll container_status inline: after POST /media succeeds, call IContainerStateStore.SaveAsync(creationId, blobName) and return true immediately.
  • Handle 429 rate-limit: ensure the Polly pipeline on the "Instagram" named client includes a retry policy respecting the Retry-After header from Meta.

2.11 New function: XPosterContainerPollingFunction

// src/XPosterContainerPollingFunction.cs
[Function("XPosterContainerPollingFunction")]
public async Task Run(
    [TimerTrigger("%ContainerPollingSchedule%")] TimerInfo timer,
    CancellationToken cancellationToken)
  • Schedule externalised via ContainerPollingSchedule app setting (default: "0 */2 * * * *" — every 2 minutes).
  • The function is sender-agnostic: it operates exclusively on IContainerStateStore and IMetaPublishingService, with no direct dependency on IgSender or any platform-specific sender.
  • Loop over IContainerStateStore.GetPendingAsync().
  • For each PendingContainer:
    • Call IMetaPublishingService.GetContainerStatusAsync(creationId).
    • FINISHEDPublishContainerAsyncIBlobStorageService.DeleteAsync(blobName)UpdateStatusAsync(Published).
    • IN_PROGRESSLogDebug + skip (no state change).
    • ERROR / EXPIREDLogWarningDeleteAsync(blobName)UpdateStatusAsync(Failed).
  • Wrap in try/catch(OperationCanceledException) — log warning, do not rethrow (same pattern as XFunction.cs).
  • Unexpected exceptions → LogError + rethrow to surface in Azure Monitor.

The function also exposes an optional [HttpTrigger] overload (GET, anonymous, /api/container-polling) for manual invocation during staging and debugging. Disabled in production via app setting ContainerPollingHttpEnabled = false.

2.12 Enable Instagram slot in DefaultSlotProfileProvider (staging gate)

Add SenderPlatform.Instagram to the existing fan-out slot at hour 6. Uncomment only after staging validation:

new ScheduledOrchestrationProfile(
    hour: 6,
    senderPlatforms: new[] { SenderPlatform.LinkedIn, SenderPlatform.Instagram, SenderPlatform.X },
    orchestratorType: typeof(FeedOrchestrator),
    textProvider:  AiProvider.OpenAi,
    imageProvider: AiProvider.AzureFoundry),

OrchestratorFactory requires no changes: sender resolution is already handled via the SenderPlatform.Instagram switch case.


Part 3 — Configuration

Update src/local.settings.json.example and docs/configuration.md:

Key Vault Secret Name Required Description
InstagramCredentialsInstagramAccessToken Long-lived Page Access Token (non-expiring)
InstagramCredentialsInstagramAccountId Instagram Business Account numeric ID
AZURE_STORAGE_CONNECTION_STRING Azure Storage connection string
AZURE_STORAGE_CONTAINER_NAME Optional Blob container name (default: xposter-images)
ContainerPollingSchedule Optional CRON expression for polling timer (default: 0 */2 * * * *)
ContainerPollingHttpEnabled Optional Enable HTTP trigger for manual polling invocation (default: false)

InstagramAccessToken and FacebookAccessToken may share the same underlying token value, but must be stored as separate Key Vault secrets — one per sender plugin.


Part 4 — Tests

tests/SenderPlugins/IgSenderTests.cs (extend existing)

  • SendAsync_WhenImageIsNull_LogsWarningAndReturnsFalse
  • SendAsync_WhenImageIsNotJpeg_LogsWarningAndReturnsFalse
  • SendAsync_WhenBlobUploadSucceeds_CreatesMediaContainerWithCorrectSasUrl
  • SendAsync_WhenBlobUploadSucceeds_AccessTokenIsNotInRequestBody
  • SendAsync_WhenBlobUploadSucceeds_SavesCreationIdToStateStore
  • SendAsync_WhenMediaContainerFails_ReturnsFalse
  • SendAsync_WhenCaptionExceedsLimit_TruncatesCaption
  • SendAsync_WhenApiReturns429_DoesNotLogRawResponseBody
  • SendAsync_DoesNotPollContainerStatus_DelegatesStateToStore

tests/Services/BlobStorageServiceTests.cs (new)

  • UploadAsync_WhenBlobClientSucceeds_ReturnsSasUri
  • UploadAsync_WhenContainerDoesNotExist_CreatesItAndUploads
  • UploadAsync_WhenStorageThrows_PropagatesException
  • DeleteAsync_WhenBlobExists_DeletesSuccessfully

tests/Functions/XPosterContainerPollingFunctionTests.cs (new)

Test Scenario
RunAsync_WhenNoPendingContainers_DoesNothing GetPendingAsync returns empty list — no Meta calls, no blob delete
RunAsync_WhenStatusIsInProgress_SkipsContainer Meta returns IN_PROGRESS — no publish, no state change
RunAsync_WhenStatusIsFinished_PublishesAndCleansUp Meta returns FINISHED → publish + blob delete + Published
RunAsync_WhenStatusIsError_MarksFailedAndCleansUp Meta returns ERROR → blob delete + Failed + log Warning
RunAsync_WhenStatusIsExpired_MarksFailedAndCleansUp Meta returns EXPIRED → same as ERROR
RunAsync_WhenPublishFails_MarksFailedAndCleansUp media_publish returns error → Failed + log Error
RunAsync_WhenBlobDeleteFails_StillUpdatesStatus DeleteAsync throws → state updated anyway; exception logged, not rethrown
RunAsync_WhenMultiplePendingContainers_ProcessesAll 3 pending records → 3 publishes, 3 deletes
RunAsync_WhenCancelled_StopsGracefully CancellationToken cancelled → OperationCanceledException not rethrown, log Warning

Part 5 — Documentation Updates

All documentation changes must be included in the same PR as the code implementation.

docs/integrations/SenderPlugins/setup-instagram.md

  • Update Step 6 and the Token Management table: remove the 60-day expiry warning and the reference to automated refresh tracked in this issue. Document that a long-lived non-expiring token is in use in production.
  • Remove the warning: "Tokens expire after 60 days. To renew, repeat Steps 5 and 6 before expiry. Automated refresh is tracked in Instagram production readiness — IBlobStorageService + IgSender activation #72."

docs/configuration.md

  • Add InstagramCredentials:InstagramAccessToken and InstagramCredentials:InstagramAccountId to the Key Vault — Required Secrets Instagram table (remove the not yet active warning once the slot is enabled).
  • Add AZURE_STORAGE_CONNECTION_STRING, AZURE_STORAGE_CONTAINER_NAME, ContainerPollingSchedule, ContainerPollingHttpEnabled as new configuration entries.

docs/architecture.md

  • Update the IgSender row in the Sender Plugins table: remove the placeholder note, reflect IBlobStorageService and IContainerStateStore dependencies.
  • Add IBlobStorageService / BlobStorageService to the Services Layer section.
  • Add IContainerStateStore / InMemoryContainerStateStore to the Services Layer section.
  • Add IMetaPublishingService / MetaPublishingService to the Services Layer section.
  • Add XPosterContainerPollingFunction to the Functions section with its trigger type and schedule.

src/local.settings.json.example

  • Add commented entries for AZURE_STORAGE_CONNECTION_STRING, AZURE_STORAGE_CONTAINER_NAME, ContainerPollingSchedule, ContainerPollingHttpEnabled under a new ── Azure Blob Storage & Instagram Polling ─── section header.

README.md

  • Update the sender status table: Instagram remains not yet active until slot is enabled after staging validation.

tests/README.md

  • Add BlobStorageServiceTests and XPosterContainerPollingFunctionTests to the test inventory.

CHANGELOG.md

  • Add an entry under the release that ships this feature: IBlobStorageService integration with SAS URL, JPEG validation, access_token query param fix, 429 handling, XPosterContainerPollingFunction async polling architecture, Instagram slot activation.

Acceptance Criteria

  • IBlobStorageService defined in XPoster.Contracts, returns SAS URL (not anonymous public URL)
  • IContainerStateStore defined in XPoster.Contracts, InMemoryContainerStateStore implemented in XPoster.Services
  • IMetaPublishingService defined in XPoster.Contracts, MetaPublishingService implemented in XPoster.Services
  • All three services registered in Program.cs via DI (no inline instantiation in sender or function)
  • src/Credentials/IgCredentials.cs renamed to src/Credentials/InstagramCredentials.cs
  • InstagramCredentials.SectionName = "InstagramCredentials"
  • Properties renamed: InstagramAccountId and InstagramAccessToken
  • InstagramCredentialsValidator implemented in src/Credentials/InstagramCredentialsValidator.cs
  • InstagramCredentialsExtensions.AddInstagramCredentials() implemented in src/Credentials/InstagramCredentialsExtensions.cs
  • builder.Services.AddInstagramCredentials(builder.Configuration) called in Program.cs
  • IgSender updated to inject IOptions<InstagramCredentials> — no inline credential reads
  • IgSender.UploadImageToPublicUrl calls IBlobStorageService.UploadAsync and returns SAS Uri
  • IgSender.SendAsync saves creation_id to IContainerStateStore and returns immediately — no inline polling
  • No NotImplementedException at runtime
  • Images validated as JPEG before upload (magic bytes FF D8 check)
  • access_token passed as query parameter, never in JSON body
  • No sensitive data (token, raw response body) in logs
  • Polly pipeline on "Instagram" client handles 429 with Retry-After
  • XPosterContainerPollingFunction implemented with TimerTrigger("%ContainerPollingSchedule%")
  • XPosterContainerPollingFunction is sender-agnostic (no direct dependency on IgSender)
  • Polling function handles FINISHED, IN_PROGRESS, ERROR, EXPIRED explicitly
  • Hard delete of blob performed by polling function after confirmed publish or failure
  • AZURE_STORAGE_*, ContainerPollingSchedule, ContainerPollingHttpEnabled documented in docs/configuration.md and local.settings.json.example
  • setup-instagram.md updated: token refresh warning and Instagram production readiness — IBlobStorageService + IgSender activation #72 reference removed
  • SenderPlatform.Instagram added to the hour-6 slot in DefaultSlotProfileProvider after staging validation
  • All unit tests pass (all new dependencies mocked via Moq)
  • All documentation files listed in Part 5 updated
  • CI green

Blocked By

  • Azure Blob Storage staging environment (Part 1)

Related

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions