فهرست منبع

feat: implement non user based API keys #50

- Add internal_api_keys table for system-level authentication
- Create internal-api-key-auth.ts middleware for internal keys
- Implement shop-data-api Edge Function supporting both key types
- Internal keys (int_shopcall_xxx) access all webshops
- User keys (api_shopcall_xxx) access only user's webshops
- Rate limiting: 200 req/min (internal), 100 req/min (user)
- Add comprehensive documentation in INTERNAL_API_KEYS.md
- Migration applied and Edge Function deployed
Claude 5 ماه پیش
والد
کامیت
7c8518266f

+ 421 - 0
INTERNAL_API_KEYS.md

@@ -0,0 +1,421 @@
+# Internal API Keys - Documentation
+
+## Overview
+
+Internal API keys provide system-level access to all webshop data without being tied to a specific user account. These keys are designed for:
+
+- Internal system integrations
+- Backend services
+- Analytics platforms
+- Administrative tools
+- Automated processes
+
+**Key Differences from User API Keys:**
+- **Internal keys** (`int_shopcall_xxx`) - Access ALL webshops across all users
+- **User keys** (`api_shopcall_xxx`) - Access only the user's own webshops
+
+## Features
+
+- ✅ Non-user-based authentication
+- ✅ Access to all connected webshops (Shopify, WooCommerce, ShopRenter)
+- ✅ Unified REST API endpoint (`shop-data-api`)
+- ✅ Rate limiting (200 requests/minute)
+- ✅ Not visible in UI (internal use only)
+- ✅ Secure bcrypt hashing
+- ✅ Optional expiration dates
+- ✅ Usage tracking (last_used_at)
+- ✅ Supports products, orders, and customers data
+
+## Database Schema
+
+### `internal_api_keys` Table
+
+```sql
+CREATE TABLE internal_api_keys (
+  id UUID PRIMARY KEY,
+  key_name TEXT NOT NULL,
+  api_key TEXT NOT NULL UNIQUE,
+  key_hash TEXT NOT NULL,
+  description TEXT,
+  permissions JSONB DEFAULT '{"all_webshops": true, "read_products": true, "read_orders": true, "read_customers": true}',
+  is_active BOOLEAN DEFAULT true,
+  last_used_at TIMESTAMPTZ,
+  expires_at TIMESTAMPTZ,
+  created_at TIMESTAMPTZ DEFAULT NOW(),
+  updated_at TIMESTAMPTZ DEFAULT NOW(),
+  created_by TEXT
+);
+```
+
+**Key Format:** `int_shopcall_[48-character-random-string]`
+
+**Permissions:** Stored as JSONB with flexible permission model
+
+## Creating an Internal API Key
+
+Internal API keys must be created **manually via SQL** (not exposed in UI):
+
+### Step 1: Generate the API Key
+
+Use the provided helper script or generate manually:
+
+```javascript
+// Generate key in Deno/Node.js
+const characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-";
+const length = 48;
+let apiKey = "int_shopcall_";
+for (let i = 0; i < length; i++) {
+  apiKey += characters.charAt(Math.floor(Math.random() * characters.length));
+}
+console.log(apiKey);
+```
+
+### Step 2: Hash the API Key
+
+Hash the API key using bcrypt (cost factor 12):
+
+```bash
+# Using bcrypt CLI (if available)
+bcrypt hash "int_shopcall_YOUR_KEY_HERE" 12
+
+# Or use online bcrypt tool (cost: 12)
+# https://bcrypt-generator.com/
+```
+
+### Step 3: Insert into Database
+
+```sql
+INSERT INTO internal_api_keys (
+  key_name,
+  api_key,
+  key_hash,
+  description,
+  created_by,
+  expires_at
+) VALUES (
+  'Analytics Service',
+  'int_shopcall_YOUR_GENERATED_KEY',
+  '$2b$12$YOUR_BCRYPT_HASH_HERE',
+  'Internal API key for analytics platform',
+  'admin',
+  NULL  -- or '2025-12-31 23:59:59+00' for expiration
+);
+```
+
+**IMPORTANT:** Store the plain `int_shopcall_xxx` key securely! It cannot be retrieved later.
+
+## API Endpoints
+
+All endpoints are available at: `https://YOUR_PROJECT.supabase.co/functions/v1/shop-data-api`
+
+### Authentication
+
+Include the internal API key in the Authorization header:
+
+```bash
+Authorization: Bearer int_shopcall_YOUR_KEY_HERE
+```
+
+### Available Endpoints
+
+#### 1. List All Stores
+```bash
+GET /shop-data-api/stores
+```
+
+**Response:**
+```json
+{
+  "success": true,
+  "data": [
+    {
+      "id": "uuid",
+      "store_name": "My Shop",
+      "store_url": "https://myshop.com",
+      "platform_name": "shopify",
+      "is_active": true,
+      "connected_at": "2025-01-15T10:00:00Z",
+      "sync_status": "completed",
+      "user_id": "uuid"
+    }
+  ],
+  "count": 1,
+  "auth_type": "internal",
+  "fetched_at": "2025-01-31T17:00:00Z"
+}
+```
+
+#### 2. List Products
+```bash
+GET /shop-data-api/products?store_id={uuid}&page=1&limit=25
+```
+
+**Query Parameters:**
+- `store_id` (required) - UUID of the store
+- `page` (optional) - Page number (default: 1)
+- `limit` (optional) - Items per page (default: 25, max: 100)
+
+**Response:**
+```json
+{
+  "success": true,
+  "platform": "shopify",
+  "data": [
+    {
+      "id": "12345",
+      "name": "Product Name",
+      "sku": "SKU-001",
+      "price": 29.99,
+      "stock_quantity": 100,
+      ...
+    }
+  ],
+  "pagination": {
+    "page": 1,
+    "limit": 25,
+    "has_more": true
+  },
+  "fetched_at": "2025-01-31T17:00:00Z"
+}
+```
+
+#### 3. List Orders
+```bash
+GET /shop-data-api/orders?store_id={uuid}&status=completed&page=1&limit=25
+```
+
+**Query Parameters:**
+- `store_id` (required)
+- `status` (optional) - Filter by order status
+- `page`, `limit` (optional)
+
+#### 4. List Customers
+```bash
+GET /shop-data-api/customers?store_id={uuid}&page=1&limit=25
+```
+
+**Query Parameters:**
+- `store_id` (required)
+- `page`, `limit` (optional)
+
+#### 5. Get Single Resource by ID
+```bash
+GET /shop-data-api/products/{id}?store_id={uuid}
+GET /shop-data-api/orders/{id}?store_id={uuid}
+GET /shop-data-api/customers/{id}?store_id={uuid}
+```
+
+## Usage Examples
+
+### cURL Example
+
+```bash
+# List all stores
+curl -X GET \
+  'https://ztklqodcdjeqpsvhlpud.supabase.co/functions/v1/shop-data-api/stores' \
+  -H 'Authorization: Bearer int_shopcall_YOUR_KEY_HERE'
+
+# Get products from a specific store
+curl -X GET \
+  'https://ztklqodcdjeqpsvhlpud.supabase.co/functions/v1/shop-data-api/products?store_id=YOUR_STORE_UUID&limit=10' \
+  -H 'Authorization: Bearer int_shopcall_YOUR_KEY_HERE'
+```
+
+### JavaScript Example
+
+```javascript
+const API_URL = 'https://ztklqodcdjeqpsvhlpud.supabase.co/functions/v1/shop-data-api';
+const INTERNAL_API_KEY = 'int_shopcall_YOUR_KEY_HERE';
+
+async function fetchAllStores() {
+  const response = await fetch(`${API_URL}/stores`, {
+    headers: {
+      'Authorization': `Bearer ${INTERNAL_API_KEY}`
+    }
+  });
+
+  if (!response.ok) {
+    throw new Error(`HTTP error! status: ${response.status}`);
+  }
+
+  const data = await response.json();
+  return data;
+}
+
+async function fetchProducts(storeId, page = 1, limit = 25) {
+  const response = await fetch(
+    `${API_URL}/products?store_id=${storeId}&page=${page}&limit=${limit}`,
+    {
+      headers: {
+        'Authorization': `Bearer ${INTERNAL_API_KEY}`
+      }
+    }
+  );
+
+  const data = await response.json();
+  return data;
+}
+```
+
+### Python Example
+
+```python
+import requests
+
+API_URL = 'https://ztklqodcdjeqpsvhlpud.supabase.co/functions/v1/shop-data-api'
+INTERNAL_API_KEY = 'int_shopcall_YOUR_KEY_HERE'
+
+def fetch_all_stores():
+    response = requests.get(
+        f'{API_URL}/stores',
+        headers={'Authorization': f'Bearer {INTERNAL_API_KEY}'}
+    )
+    response.raise_for_status()
+    return response.json()
+
+def fetch_products(store_id, page=1, limit=25):
+    response = requests.get(
+        f'{API_URL}/products',
+        params={'store_id': store_id, 'page': page, 'limit': limit},
+        headers={'Authorization': f'Bearer {INTERNAL_API_KEY}'}
+    )
+    response.raise_for_status()
+    return response.json()
+```
+
+## Rate Limiting
+
+- **Internal API Keys:** 200 requests/minute
+- **User API Keys:** 100 requests/minute
+
+Rate limit headers are included in responses:
+```
+X-RateLimit-Limit: 200
+X-RateLimit-Remaining: 195
+X-RateLimit-Reset: 2025-01-31T17:01:00Z
+```
+
+## Error Responses
+
+### 401 Unauthorized
+```json
+{
+  "error": "Internal API key not found",
+  "code": "NOT_FOUND",
+  "timestamp": "2025-01-31T17:00:00Z"
+}
+```
+
+### 429 Rate Limit Exceeded
+```json
+{
+  "error": "Rate limit exceeded. Try again after 2025-01-31T17:01:00Z",
+  "code": "RATE_LIMIT_EXCEEDED",
+  "timestamp": "2025-01-31T17:00:00Z"
+}
+```
+
+### 404 Store Not Found
+```json
+{
+  "error": "Store not found or access denied",
+  "code": "STORE_NOT_FOUND"
+}
+```
+
+## Security Best Practices
+
+1. **Store keys securely** - Never commit keys to version control
+2. **Use environment variables** - Store keys in .env files
+3. **Rotate keys regularly** - Create new keys and revoke old ones
+4. **Set expiration dates** - Add `expires_at` for temporary access
+5. **Monitor usage** - Check `last_used_at` for suspicious activity
+6. **Limit key count** - Only create keys when necessary
+
+## Management Functions
+
+### Check Key Status
+
+```sql
+SELECT
+  id,
+  key_name,
+  is_active,
+  last_used_at,
+  expires_at,
+  created_at
+FROM internal_api_keys
+WHERE api_key = 'int_shopcall_YOUR_KEY';
+```
+
+### Revoke a Key
+
+```sql
+UPDATE internal_api_keys
+SET is_active = false
+WHERE id = 'key-uuid';
+
+-- Or use helper function
+SELECT revoke_internal_api_key('key-uuid');
+```
+
+### List Active Keys
+
+```sql
+SELECT
+  id,
+  key_name,
+  description,
+  created_by,
+  last_used_at,
+  created_at
+FROM internal_api_keys
+WHERE is_active = true
+ORDER BY created_at DESC;
+```
+
+### Cleanup Expired Keys
+
+```sql
+-- Delete keys expired >30 days ago
+SELECT cleanup_expired_internal_api_keys();
+```
+
+## Differences from User API Keys
+
+| Feature | Internal API Keys | User API Keys |
+|---------|------------------|---------------|
+| Format | `int_shopcall_xxx` | `api_shopcall_xxx` |
+| Access Scope | All webshops | User's webshops only |
+| Rate Limit | 200 req/min | 100 req/min |
+| UI Visibility | Hidden | Visible to users |
+| Creation | Manual SQL | Via API endpoint |
+| Management | Database only | User dashboard |
+| Use Case | System integration | User applications |
+
+## Migration Applied
+
+Migration file: `supabase/migrations/20251031_170000_internal_api_keys_table.sql`
+
+The migration creates:
+- ✅ `internal_api_keys` table with RLS
+- ✅ Helper functions for validation and management
+- ✅ Triggers for timestamp updates
+- ✅ Indexes for performance
+
+## Related Files
+
+- **Migration:** `supabase/migrations/20251031_170000_internal_api_keys_table.sql`
+- **Auth Middleware:** `supabase/functions/_shared/internal-api-key-auth.ts`
+- **API Endpoint:** `supabase/functions/shop-data-api/index.ts`
+
+## Support
+
+For issues or questions:
+- Related Issue: #50
+- Documentation: This file
+- Contact: Development team
+
+---
+
+**Note:** Internal API keys are powerful! Only share them with trusted systems and services.

+ 298 - 0
supabase/functions/_shared/internal-api-key-auth.ts

@@ -0,0 +1,298 @@
+/**
+ * Internal API Key Authentication Middleware
+ *
+ * Provides authentication for REST API endpoints using internal (non-user-based) API keys.
+ * These keys allow system-level access to all webshop data without being tied to a specific user.
+ *
+ * Related: Issue #50 - Implement non user based API keys
+ */
+
+import { createClient, SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.39.7";
+
+// Rate limiting configuration (stricter for internal keys)
+const INTERNAL_RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
+const INTERNAL_RATE_LIMIT_MAX_REQUESTS = 200; // 200 requests per minute
+
+// In-memory rate limiting store
+const internalRateLimitStore = new Map<string, { count: number; resetAt: number }>();
+
+export interface InternalApiKeyValidationResult {
+  valid: boolean;
+  permissions?: Record<string, unknown>;
+  errorMessage?: string;
+  errorCode?: string;
+}
+
+export interface RateLimitResult {
+  allowed: boolean;
+  remaining: number;
+  resetAt: number;
+}
+
+/**
+ * Extract internal API key from Authorization header
+ */
+export function extractInternalApiKey(request: Request): string | null {
+  const authHeader = request.headers.get("Authorization");
+
+  if (!authHeader) {
+    return null;
+  }
+
+  // Support both "Bearer int_shopcall_xxx" and "int_shopcall_xxx" formats
+  const match = authHeader.match(/^(?:Bearer\s+)?(int_shopcall_[a-zA-Z0-9_-]+)$/i);
+  return match ? match[1] : null;
+}
+
+/**
+ * Validate internal API key format
+ */
+export function isValidInternalApiKeyFormat(apiKey: string): boolean {
+  return /^int_shopcall_[a-zA-Z0-9_-]{32,}$/.test(apiKey);
+}
+
+/**
+ * Check rate limit for an internal API key
+ */
+export function checkInternalRateLimit(apiKey: string): RateLimitResult {
+  const now = Date.now();
+  const record = internalRateLimitStore.get(apiKey);
+
+  // Clean up expired entries
+  if (record && now > record.resetAt) {
+    internalRateLimitStore.delete(apiKey);
+  }
+
+  const currentRecord = internalRateLimitStore.get(apiKey);
+
+  if (!currentRecord) {
+    // First request in this window
+    const resetAt = now + INTERNAL_RATE_LIMIT_WINDOW_MS;
+    internalRateLimitStore.set(apiKey, { count: 1, resetAt });
+    return {
+      allowed: true,
+      remaining: INTERNAL_RATE_LIMIT_MAX_REQUESTS - 1,
+      resetAt,
+    };
+  }
+
+  if (currentRecord.count >= INTERNAL_RATE_LIMIT_MAX_REQUESTS) {
+    // Rate limit exceeded
+    return {
+      allowed: false,
+      remaining: 0,
+      resetAt: currentRecord.resetAt,
+    };
+  }
+
+  // Increment counter
+  currentRecord.count++;
+  internalRateLimitStore.set(apiKey, currentRecord);
+
+  return {
+    allowed: true,
+    remaining: INTERNAL_RATE_LIMIT_MAX_REQUESTS - currentRecord.count,
+    resetAt: currentRecord.resetAt,
+  };
+}
+
+/**
+ * Validate internal API key against database
+ */
+export async function validateInternalApiKey(
+  apiKey: string,
+  supabaseAdmin: SupabaseClient,
+  requiredPermission?: string
+): Promise<InternalApiKeyValidationResult> {
+  try {
+    // Check API key format
+    if (!isValidInternalApiKeyFormat(apiKey)) {
+      return {
+        valid: false,
+        errorMessage: "Invalid internal API key format",
+        errorCode: "INVALID_FORMAT",
+      };
+    }
+
+    // Query database for internal API key
+    const { data: keyData, error: keyError } = await supabaseAdmin
+      .from("internal_api_keys")
+      .select("*")
+      .eq("api_key", apiKey)
+      .single();
+
+    if (keyError || !keyData) {
+      return {
+        valid: false,
+        errorMessage: "Internal API key not found",
+        errorCode: "NOT_FOUND",
+      };
+    }
+
+    // Check if key is active
+    if (!keyData.is_active) {
+      return {
+        valid: false,
+        errorMessage: "Internal API key is inactive",
+        errorCode: "INACTIVE",
+      };
+    }
+
+    // Check expiration
+    if (keyData.expires_at) {
+      const expiresAt = new Date(keyData.expires_at);
+      if (expiresAt < new Date()) {
+        return {
+          valid: false,
+          errorMessage: "Internal API key has expired",
+          errorCode: "EXPIRED",
+        };
+      }
+    }
+
+    // Check specific permission if required
+    if (requiredPermission && keyData.permissions) {
+      const permissions = keyData.permissions as Record<string, unknown>;
+      if (!permissions[requiredPermission]) {
+        return {
+          valid: false,
+          errorMessage: `Missing required permission: ${requiredPermission}`,
+          errorCode: "INSUFFICIENT_PERMISSIONS",
+        };
+      }
+    }
+
+    // Update last_used_at timestamp (non-blocking)
+    supabaseAdmin
+      .from("internal_api_keys")
+      .update({ last_used_at: new Date().toISOString() })
+      .eq("api_key", apiKey)
+      .then(() => {})
+      .catch((err) => console.error("Failed to update last_used_at for internal key:", err));
+
+    return {
+      valid: true,
+      permissions: keyData.permissions as Record<string, unknown>,
+    };
+  } catch (error) {
+    console.error("Error validating internal API key:", error);
+    return {
+      valid: false,
+      errorMessage: "Internal error during API key validation",
+      errorCode: "INTERNAL_ERROR",
+    };
+  }
+}
+
+/**
+ * Generate a new internal API key
+ */
+export function generateInternalApiKey(): string {
+  const characters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-";
+  const length = 48; // 48 characters for strong security
+  let result = "int_shopcall_";
+
+  for (let i = 0; i < length; i++) {
+    result += characters.charAt(Math.floor(Math.random() * characters.length));
+  }
+
+  return result;
+}
+
+/**
+ * Middleware function to protect Edge Function endpoints with internal API keys
+ *
+ * Usage:
+ * ```typescript
+ * const result = await requireInternalApiKey(req, supabaseAdmin);
+ * if (!result.valid) {
+ *   return new Response(JSON.stringify({ error: result.errorMessage }), { status: 401 });
+ * }
+ * ```
+ */
+export async function requireInternalApiKey(
+  request: Request,
+  supabaseAdmin: SupabaseClient,
+  requiredPermission?: string
+): Promise<InternalApiKeyValidationResult> {
+  // Extract internal API key from header
+  const apiKey = extractInternalApiKey(request);
+
+  if (!apiKey) {
+    return {
+      valid: false,
+      errorMessage: "Missing internal API key. Use Authorization: Bearer int_shopcall_xxx",
+      errorCode: "MISSING_API_KEY",
+    };
+  }
+
+  // Check rate limit
+  const rateLimitResult = checkInternalRateLimit(apiKey);
+  if (!rateLimitResult.allowed) {
+    return {
+      valid: false,
+      errorMessage: `Rate limit exceeded. Try again after ${new Date(rateLimitResult.resetAt).toISOString()}`,
+      errorCode: "RATE_LIMIT_EXCEEDED",
+    };
+  }
+
+  // Validate internal API key
+  const validationResult = await validateInternalApiKey(apiKey, supabaseAdmin, requiredPermission);
+
+  return validationResult;
+}
+
+/**
+ * Create error response for internal API key validation failures
+ */
+export function createInternalApiKeyErrorResponse(result: InternalApiKeyValidationResult): Response {
+  const statusCode = result.errorCode === "RATE_LIMIT_EXCEEDED" ? 429 : 401;
+
+  return new Response(
+    JSON.stringify({
+      error: result.errorMessage,
+      code: result.errorCode,
+      timestamp: new Date().toISOString(),
+    }),
+    {
+      status: statusCode,
+      headers: {
+        "Content-Type": "application/json",
+        "WWW-Authenticate": 'Bearer realm="Internal API", error="invalid_token"',
+      },
+    }
+  );
+}
+
+/**
+ * Add rate limit headers to response for internal API
+ */
+export function addInternalRateLimitHeaders(response: Response, apiKey: string): Response {
+  const rateLimitResult = checkInternalRateLimit(apiKey);
+
+  const headers = new Headers(response.headers);
+  headers.set("X-RateLimit-Limit", INTERNAL_RATE_LIMIT_MAX_REQUESTS.toString());
+  headers.set("X-RateLimit-Remaining", rateLimitResult.remaining.toString());
+  headers.set("X-RateLimit-Reset", new Date(rateLimitResult.resetAt).toISOString());
+
+  return new Response(response.body, {
+    status: response.status,
+    statusText: response.statusText,
+    headers,
+  });
+}
+
+/**
+ * Cleanup expired rate limit entries (call periodically)
+ */
+export function cleanupInternalRateLimitStore(): void {
+  const now = Date.now();
+  for (const [key, value] of internalRateLimitStore.entries()) {
+    if (now > value.resetAt) {
+      internalRateLimitStore.delete(key);
+    }
+  }
+}
+
+// Run cleanup every 5 minutes
+setInterval(cleanupInternalRateLimitStore, 5 * 60 * 1000);

+ 569 - 0
supabase/functions/shop-data-api/index.ts

@@ -0,0 +1,569 @@
+/**
+ * Shop Data API - Unified REST API for E-commerce Data Access (Internal + User API Keys)
+ *
+ * Provides real-time access to customer, order, and product data from ALL connected
+ * webshops using internal (non-user-based) API keys OR user-based API keys.
+ *
+ * Internal API Keys: System-level access to all webshops (not tied to specific user)
+ * User API Keys: User-specific access (existing functionality)
+ *
+ * Related: Issue #50 - Implement non user based API keys
+ *
+ * Authentication: API Key (Bearer token)
+ * - int_shopcall_xxx (internal keys - access all shops)
+ * - api_shopcall_xxx (user keys - access only user's shops)
+ *
+ * Rate Limit: 200 requests/minute (internal), 100 requests/minute (user)
+ *
+ * Endpoints:
+ * - GET /shop-data-api/stores - List all stores (internal only) or user's stores
+ * - GET /shop-data-api/customers?store_id={uuid}&page=1&limit=25
+ * - GET /shop-data-api/customers/{id}?store_id={uuid}
+ * - GET /shop-data-api/orders?store_id={uuid}&status=completed&page=1
+ * - GET /shop-data-api/orders/{id}?store_id={uuid}
+ * - GET /shop-data-api/products?store_id={uuid}&page=1
+ * - GET /shop-data-api/products/{id}?store_id={uuid}
+ */
+
+import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.7";
+import { wrapHandler } from "../_shared/error-handler.ts";
+
+// Import authentication for both API key types
+import {
+  requireApiKey,
+  createApiKeyErrorResponse,
+  addRateLimitHeaders,
+  extractApiKey,
+} from "../_shared/api-key-auth.ts";
+
+import {
+  requireInternalApiKey,
+  createInternalApiKeyErrorResponse,
+  addInternalRateLimitHeaders,
+  extractInternalApiKey,
+} from "../_shared/internal-api-key-auth.ts";
+
+import { getAdapters } from "../_shared/platform-adapters.ts";
+
+// Import platform-specific clients
+import * as ShopifyClient from "../_shared/shopify-client.ts";
+import * as WooCommerceClient from "../_shared/woocommerce-client.ts";
+import * as ShopRenterClient from "../_shared/shoprenter-client.ts";
+
+const FUNCTION_NAME = "shop-data-api";
+
+interface PaginationMeta {
+  page: number;
+  limit: number;
+  total?: number;
+  has_more?: boolean;
+}
+
+interface ApiResponse {
+  success: boolean;
+  platform?: string;
+  data: unknown;
+  pagination?: PaginationMeta;
+  fetched_at: string;
+}
+
+interface AuthContext {
+  type: "user" | "internal";
+  userId?: string; // Only for user API keys
+  apiKey: string;
+}
+
+/**
+ * Authenticate request with either user or internal API key
+ */
+async function authenticateRequest(
+  req: Request,
+  supabaseAdmin: any
+): Promise<{ context: AuthContext | null; errorResponse: Response | null }> {
+  // Try internal API key first (int_shopcall_xxx)
+  const internalKey = extractInternalApiKey(req);
+  if (internalKey) {
+    const result = await requireInternalApiKey(req, supabaseAdmin);
+    if (!result.valid) {
+      return { context: null, errorResponse: createInternalApiKeyErrorResponse(result) };
+    }
+    return {
+      context: { type: "internal", apiKey: internalKey },
+      errorResponse: null,
+    };
+  }
+
+  // Try user API key (api_shopcall_xxx)
+  const userKey = extractApiKey(req);
+  if (userKey) {
+    const result = await requireApiKey(req, supabaseAdmin, "webshop_data");
+    if (!result.valid) {
+      return { context: null, errorResponse: createApiKeyErrorResponse(result) };
+    }
+    return {
+      context: { type: "user", userId: result.userId!, apiKey: userKey },
+      errorResponse: null,
+    };
+  }
+
+  // No valid API key found
+  return {
+    context: null,
+    errorResponse: new Response(
+      JSON.stringify({
+        error: "Missing API key. Provide either int_shopcall_xxx or api_shopcall_xxx",
+        code: "MISSING_API_KEY",
+      }),
+      { status: 401, headers: { "Content-Type": "application/json" } }
+    ),
+  };
+}
+
+/**
+ * Main handler function
+ */
+async function handler(req: Request): Promise<Response> {
+  const supabaseUrl = Deno.env.get("SUPABASE_URL");
+  const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
+
+  if (!supabaseUrl || !supabaseServiceKey) {
+    return new Response(
+      JSON.stringify({ error: "Server configuration error" }),
+      { status: 500, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
+
+  // Only allow GET requests
+  if (req.method !== "GET") {
+    return new Response(
+      JSON.stringify({ error: "Method not allowed" }),
+      { status: 405, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Authenticate request
+  const { context, errorResponse } = await authenticateRequest(req, supabaseAdmin);
+  if (errorResponse) {
+    return errorResponse;
+  }
+
+  const authContext = context!;
+
+  // Parse URL and query parameters
+  const url = new URL(req.url);
+  const pathParts = url.pathname.split("/").filter((p) => p);
+
+  // Expected path: /shop-data-api/{resource}/{id?}
+  if (pathParts[0] !== "shop-data-api") {
+    return new Response(
+      JSON.stringify({ error: "Invalid endpoint" }),
+      { status: 404, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  const resource = pathParts[1]; // 'stores', 'customers', 'orders', or 'products'
+  const resourceId = pathParts[2]; // Optional ID
+
+  // Handle special endpoint: list stores
+  if (resource === "stores") {
+    const response = await handleListStores(supabaseAdmin, authContext);
+    return addRateLimitHeadersToResponse(response, authContext);
+  }
+
+  // Get query parameters
+  const storeId = url.searchParams.get("store_id");
+  const page = parseInt(url.searchParams.get("page") || "1");
+  const limit = parseInt(url.searchParams.get("limit") || "25");
+  const status = url.searchParams.get("status"); // For orders
+
+  // Validate required parameters
+  if (!storeId) {
+    return new Response(
+      JSON.stringify({
+        error: "Missing required parameter: store_id",
+        code: "MISSING_STORE_ID",
+      }),
+      { status: 400, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  if (!["customers", "orders", "products"].includes(resource)) {
+    return new Response(
+      JSON.stringify({
+        error: "Invalid resource. Must be stores, customers, orders, or products",
+        code: "INVALID_RESOURCE",
+      }),
+      { status: 400, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Validate pagination parameters
+  if (page < 1 || limit < 1 || limit > 100) {
+    return new Response(
+      JSON.stringify({
+        error: "Invalid pagination parameters. Page must be >= 1, limit must be 1-100",
+        code: "INVALID_PAGINATION",
+      }),
+      { status: 400, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Fetch store and verify access
+  const storeQuery = supabaseAdmin
+    .from("stores")
+    .select("*")
+    .eq("id", storeId)
+    .eq("is_active", true);
+
+  // For user API keys, restrict to user's stores
+  if (authContext.type === "user") {
+    storeQuery.eq("user_id", authContext.userId);
+  }
+
+  const { data: store, error: storeError } = await storeQuery.single();
+
+  if (storeError || !store) {
+    return new Response(
+      JSON.stringify({
+        error: "Store not found or access denied",
+        code: "STORE_NOT_FOUND",
+      }),
+      { status: 404, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  // Check data access permissions (only for user keys, internal keys have full access)
+  if (authContext.type === "user") {
+    const permissions = store.data_access_permissions || {};
+    const dataType = resource.slice(0, -1); // Remove 's' (customers -> customer)
+
+    if (!permissions[`allow_${dataType}_access`]) {
+      return new Response(
+        JSON.stringify({
+          error: `Access to ${resource} is not enabled for this store`,
+          code: "ACCESS_DENIED",
+          hint: "Enable access in store settings",
+        }),
+        { status: 403, headers: { "Content-Type": "application/json" } }
+      );
+    }
+  }
+
+  // Fetch data based on resource and platform
+  let responseData: ApiResponse;
+
+  try {
+    switch (store.platform_name.toLowerCase()) {
+      case "shopify":
+        responseData = await fetchShopifyData(
+          store,
+          resource,
+          resourceId,
+          page,
+          limit,
+          status
+        );
+        break;
+      case "woocommerce":
+        responseData = await fetchWooCommerceData(
+          store,
+          resource,
+          resourceId,
+          page,
+          limit,
+          status
+        );
+        break;
+      case "shoprenter":
+        responseData = await fetchShopRenterData(
+          store,
+          resource,
+          resourceId,
+          page,
+          limit,
+          status
+        );
+        break;
+      default:
+        return new Response(
+          JSON.stringify({
+            error: `Unsupported platform: ${store.platform_name}`,
+            code: "UNSUPPORTED_PLATFORM",
+          }),
+          { status: 400, headers: { "Content-Type": "application/json" } }
+        );
+    }
+
+    const response = new Response(JSON.stringify(responseData), {
+      status: 200,
+      headers: { "Content-Type": "application/json" },
+    });
+
+    // Add rate limit headers
+    return addRateLimitHeadersToResponse(response, authContext);
+  } catch (error) {
+    console.error(`Error fetching ${resource}:`, error);
+
+    return new Response(
+      JSON.stringify({
+        error: error instanceof Error ? error.message : "Failed to fetch data",
+        code: "FETCH_ERROR",
+      }),
+      { status: 500, headers: { "Content-Type": "application/json" } }
+    );
+  }
+}
+
+/**
+ * Handle listing stores
+ */
+async function handleListStores(
+  supabaseAdmin: any,
+  authContext: AuthContext
+): Promise<Response> {
+  const query = supabaseAdmin
+    .from("stores")
+    .select("id, store_name, store_url, platform_name, is_active, connected_at, sync_status, user_id")
+    .eq("is_active", true);
+
+  // For user API keys, only show user's stores
+  if (authContext.type === "user") {
+    query.eq("user_id", authContext.userId);
+  }
+
+  const { data: stores, error } = await query;
+
+  if (error) {
+    return new Response(
+      JSON.stringify({
+        error: "Failed to fetch stores",
+        code: "FETCH_ERROR",
+      }),
+      { status: 500, headers: { "Content-Type": "application/json" } }
+    );
+  }
+
+  return new Response(
+    JSON.stringify({
+      success: true,
+      data: stores,
+      count: stores.length,
+      auth_type: authContext.type,
+      fetched_at: new Date().toISOString(),
+    }),
+    { status: 200, headers: { "Content-Type": "application/json" } }
+  );
+}
+
+/**
+ * Add appropriate rate limit headers based on auth context
+ */
+function addRateLimitHeadersToResponse(response: Response, authContext: AuthContext): Response {
+  if (authContext.type === "internal") {
+    return addInternalRateLimitHeaders(response, authContext.apiKey);
+  } else {
+    return addRateLimitHeaders(response, authContext.apiKey);
+  }
+}
+
+/**
+ * Fetch data from Shopify
+ */
+async function fetchShopifyData(
+  store: any,
+  resource: string,
+  resourceId: string | undefined,
+  page: number,
+  limit: number,
+  status: string | null
+): Promise<ApiResponse> {
+  const adapters = getAdapters("shopify");
+
+  if (resourceId) {
+    // Fetch single item
+    const itemId = parseInt(resourceId);
+    let data;
+
+    if (resource === "customers") {
+      const response = await ShopifyClient.shopifyApiRequest(
+        store.id,
+        `/customers/${itemId}.json`
+      );
+      data = adapters.customer(response.customer);
+    } else if (resource === "orders") {
+      const response = await ShopifyClient.shopifyApiRequest(
+        store.id,
+        `/orders/${itemId}.json`
+      );
+      data = adapters.order(response.order);
+    } else if (resource === "products") {
+      const response = await ShopifyClient.shopifyApiRequest(
+        store.id,
+        `/products/${itemId}.json`
+      );
+      data = adapters.product(response.product);
+    }
+
+    return {
+      success: true,
+      platform: "shopify",
+      data,
+      fetched_at: new Date().toISOString(),
+    };
+  } else {
+    // Fetch list
+    const sinceId = page > 1 ? undefined : undefined; // Shopify uses since_id for pagination
+    let data;
+    let rawData;
+
+    if (resource === "customers") {
+      rawData = await ShopifyClient.fetchCustomers(store.id, limit, sinceId);
+      data = rawData.map((item: any) => adapters.customer(item));
+    } else if (resource === "orders") {
+      let endpoint = `/orders.json?limit=${limit}&status=${status || "any"}`;
+      const response = await ShopifyClient.shopifyApiRequest(store.id, endpoint);
+      rawData = response.orders || [];
+      data = rawData.map((item: any) => adapters.order(item));
+    } else if (resource === "products") {
+      rawData = await ShopifyClient.fetchProducts(store.id, limit, sinceId);
+      data = rawData.map((item: any) => adapters.product(item));
+    }
+
+    return {
+      success: true,
+      platform: "shopify",
+      data,
+      pagination: {
+        page,
+        limit,
+        has_more: (rawData?.length || 0) === limit,
+      },
+      fetched_at: new Date().toISOString(),
+    };
+  }
+}
+
+/**
+ * Fetch data from WooCommerce
+ */
+async function fetchWooCommerceData(
+  store: any,
+  resource: string,
+  resourceId: string | undefined,
+  page: number,
+  limit: number,
+  status: string | null
+): Promise<ApiResponse> {
+  const adapters = getAdapters("woocommerce");
+
+  if (resourceId) {
+    // Fetch single item
+    const itemId = parseInt(resourceId);
+    let data;
+
+    if (resource === "customers") {
+      const rawData = await WooCommerceClient.fetchCustomer(store.id, itemId);
+      data = adapters.customer(rawData);
+    } else if (resource === "orders") {
+      const rawData = await WooCommerceClient.fetchOrder(store.id, itemId);
+      data = adapters.order(rawData);
+    } else if (resource === "products") {
+      const rawData = await WooCommerceClient.fetchProduct(store.id, itemId);
+      data = adapters.product(rawData);
+    }
+
+    return {
+      success: true,
+      platform: "woocommerce",
+      data,
+      fetched_at: new Date().toISOString(),
+    };
+  } else {
+    // Fetch list
+    let data;
+    let rawData;
+
+    if (resource === "customers") {
+      rawData = await WooCommerceClient.fetchCustomers(store.id, page, limit);
+      data = rawData.map((item: any) => adapters.customer(item));
+    } else if (resource === "orders") {
+      rawData = await WooCommerceClient.fetchOrders(
+        store.id,
+        page,
+        limit,
+        status || undefined
+      );
+      data = rawData.map((item: any) => adapters.order(item));
+    } else if (resource === "products") {
+      rawData = await WooCommerceClient.fetchProducts(store.id, page, limit);
+      data = rawData.map((item: any) => adapters.product(item));
+    }
+
+    return {
+      success: true,
+      platform: "woocommerce",
+      data,
+      pagination: {
+        page,
+        limit,
+        has_more: (rawData?.length || 0) === limit,
+      },
+      fetched_at: new Date().toISOString(),
+    };
+  }
+}
+
+/**
+ * Fetch data from ShopRenter
+ */
+async function fetchShopRenterData(
+  store: any,
+  resource: string,
+  resourceId: string | undefined,
+  page: number,
+  limit: number,
+  status: string | null
+): Promise<ApiResponse> {
+  const adapters = getAdapters("shoprenter");
+
+  if (resourceId) {
+    // Fetch single item - ShopRenter client doesn't have individual fetch methods
+    // So we'll fetch the list and filter (or implement individual fetches)
+    throw new Error("Single item fetch not yet implemented for ShopRenter");
+  } else {
+    // Fetch list
+    let data;
+    let rawData;
+
+    if (resource === "customers") {
+      const response = await ShopRenterClient.fetchCustomers(store.id, page, limit);
+      rawData = response.items || [];
+      data = rawData.map((item: any) => adapters.customer(item));
+    } else if (resource === "orders") {
+      const response = await ShopRenterClient.fetchOrders(store.id, page, limit);
+      rawData = response.items || [];
+      data = rawData.map((item: any) => adapters.order(item));
+    } else if (resource === "products") {
+      const response = await ShopRenterClient.fetchProducts(store.id, page, limit);
+      rawData = response.items || [];
+      data = rawData.map((item: any) => adapters.product(item));
+    }
+
+    return {
+      success: true,
+      platform: "shoprenter",
+      data,
+      pagination: {
+        page,
+        limit,
+        has_more: (rawData?.length || 0) === limit,
+      },
+      fetched_at: new Date().toISOString(),
+    };
+  }
+}
+
+// Wrap handler with error handling
+Deno.serve(wrapHandler(FUNCTION_NAME, handler));

+ 168 - 0
supabase/migrations/20251031_170000_internal_api_keys_table.sql

@@ -0,0 +1,168 @@
+-- Migration: Internal API Keys Table for System-Level Access
+-- Description: Creates internal_api_keys table for non-user-based API authentication
+-- Date: 2025-10-31
+-- Related: Issue #50 - Implement non user based API keys
+
+-- ============================================================================
+-- STEP 1: Create internal_api_keys Table
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS internal_api_keys (
+  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+  key_name TEXT NOT NULL,
+  api_key TEXT NOT NULL UNIQUE,
+  key_hash TEXT NOT NULL, -- bcrypt hash for validation
+  description TEXT,
+  permissions JSONB NOT NULL DEFAULT '{"all_webshops": true, "read_products": true, "read_orders": true, "read_customers": true}'::jsonb,
+  is_active BOOLEAN NOT NULL DEFAULT true,
+  last_used_at TIMESTAMPTZ,
+  expires_at TIMESTAMPTZ,
+  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  created_by TEXT, -- optional: track who created this key (e.g., "admin", "system")
+
+  -- Constraints
+  CONSTRAINT internal_key_name_length CHECK (char_length(key_name) >= 3 AND char_length(key_name) <= 100),
+  CONSTRAINT internal_api_key_format CHECK (api_key LIKE 'int_shopcall_%')
+);
+
+-- Create indexes for performance
+CREATE INDEX idx_internal_api_keys_api_key ON internal_api_keys(api_key);
+CREATE INDEX idx_internal_api_keys_is_active ON internal_api_keys(is_active);
+CREATE INDEX idx_internal_api_keys_expires_at ON internal_api_keys(expires_at) WHERE expires_at IS NOT NULL;
+
+-- ============================================================================
+-- STEP 2: Add Updated Timestamp Trigger
+-- ============================================================================
+
+CREATE OR REPLACE FUNCTION update_internal_api_keys_timestamp()
+RETURNS TRIGGER AS $$
+BEGIN
+  NEW.updated_at = NOW();
+  RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_internal_api_keys_updated_at
+  BEFORE UPDATE ON internal_api_keys
+  FOR EACH ROW
+  EXECUTE FUNCTION update_internal_api_keys_timestamp();
+
+-- ============================================================================
+-- STEP 3: Enable Row Level Security (RLS)
+-- ============================================================================
+
+ALTER TABLE internal_api_keys ENABLE ROW LEVEL SECURITY;
+
+-- Policy: Service role can do anything (for internal management)
+CREATE POLICY "Service role full access to internal API keys"
+  ON internal_api_keys
+  FOR ALL
+  USING (auth.jwt() ->> 'role' = 'service_role');
+
+-- Policy: No regular user access (internal only)
+-- This ensures internal_api_keys are NOT visible in the UI
+
+-- ============================================================================
+-- STEP 4: Create Helper Functions
+-- ============================================================================
+
+-- Function to check if an internal API key is valid and active
+CREATE OR REPLACE FUNCTION is_internal_api_key_valid(
+  p_api_key TEXT
+)
+RETURNS TABLE (
+  valid BOOLEAN,
+  permissions JSONB,
+  error_message TEXT
+) AS $$
+BEGIN
+  RETURN QUERY
+  SELECT
+    CASE
+      WHEN k.id IS NULL THEN false
+      WHEN NOT k.is_active THEN false
+      WHEN k.expires_at IS NOT NULL AND k.expires_at < NOW() THEN false
+      ELSE true
+    END AS valid,
+    k.permissions,
+    CASE
+      WHEN k.id IS NULL THEN 'Internal API key not found'
+      WHEN NOT k.is_active THEN 'Internal API key is inactive'
+      WHEN k.expires_at IS NOT NULL AND k.expires_at < NOW() THEN 'Internal API key has expired'
+      ELSE NULL
+    END AS error_message
+  FROM internal_api_keys k
+  WHERE k.api_key = p_api_key
+  LIMIT 1;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Function to update last_used_at timestamp for internal keys
+CREATE OR REPLACE FUNCTION update_internal_api_key_last_used(
+  p_api_key TEXT
+)
+RETURNS void AS $$
+BEGIN
+  UPDATE internal_api_keys
+  SET last_used_at = NOW()
+  WHERE api_key = p_api_key AND is_active = true;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Function to revoke an internal API key (service role only)
+CREATE OR REPLACE FUNCTION revoke_internal_api_key(
+  p_key_id UUID
+)
+RETURNS BOOLEAN AS $$
+DECLARE
+  rows_affected INTEGER;
+BEGIN
+  UPDATE internal_api_keys
+  SET is_active = false, updated_at = NOW()
+  WHERE id = p_key_id;
+
+  GET DIAGNOSTICS rows_affected = ROW_COUNT;
+  RETURN rows_affected > 0;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- Function to clean up expired internal API keys
+CREATE OR REPLACE FUNCTION cleanup_expired_internal_api_keys()
+RETURNS INTEGER AS $$
+DECLARE
+  rows_deleted INTEGER;
+BEGIN
+  DELETE FROM internal_api_keys
+  WHERE expires_at IS NOT NULL AND expires_at < NOW() - INTERVAL '30 days';
+
+  GET DIAGNOSTICS rows_deleted = ROW_COUNT;
+  RETURN rows_deleted;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+-- ============================================================================
+-- STEP 5: Comments for Documentation
+-- ============================================================================
+
+COMMENT ON TABLE internal_api_keys IS 'Stores internal/system API keys for non-user-based access to all webshop data. Not visible in UI.';
+COMMENT ON COLUMN internal_api_keys.api_key IS 'Plain internal API key (format: int_shopcall_xxxxx)';
+COMMENT ON COLUMN internal_api_keys.key_hash IS 'Bcrypt hash of internal API key for validation';
+COMMENT ON COLUMN internal_api_keys.permissions IS 'JSON object defining what the internal API key can access (typically all_webshops: true)';
+COMMENT ON COLUMN internal_api_keys.last_used_at IS 'Timestamp of last successful internal API key usage';
+COMMENT ON COLUMN internal_api_keys.expires_at IS 'Optional expiration date for the internal API key';
+COMMENT ON COLUMN internal_api_keys.created_by IS 'Optional field to track who created this internal key';
+
+-- ============================================================================
+-- STEP 6: Grant Necessary Permissions
+-- ============================================================================
+
+-- Grant usage on functions to service role (internal management)
+GRANT EXECUTE ON FUNCTION is_internal_api_key_valid(TEXT) TO service_role;
+GRANT EXECUTE ON FUNCTION update_internal_api_key_last_used(TEXT) TO service_role;
+GRANT EXECUTE ON FUNCTION revoke_internal_api_key(UUID) TO service_role;
+GRANT EXECUTE ON FUNCTION cleanup_expired_internal_api_keys() TO service_role;
+
+-- Also grant to anon for validation during API requests
+GRANT EXECUTE ON FUNCTION is_internal_api_key_valid(TEXT) TO anon;
+GRANT EXECUTE ON FUNCTION update_internal_api_key_last_used(TEXT) TO anon;