Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
190373b
feat(ai): spike official image provider behind release gate
alirezasafaeigfx Oct 11, 2026
d6d83b9
fix(ai): satisfy curly guard in image adapter
alirezasafaeigfx Oct 11, 2026
b064348
feat(ai): add default-off image admission and atomic quota guards
alirezasafaeigfx Oct 11, 2026
9598450
fix(ai): use type-only NextRequest import
alirezasafaeigfx Oct 11, 2026
6b230a4
ci(ai): verify image quota against disposable PostgreSQL
alirezasafaeigfx Oct 11, 2026
b1befea
ci(ai): avoid credential-bearing PostgreSQL URL in repository
alirezasafaeigfx Oct 11, 2026
284ca0a
feat(ai): add gated Persian image preview and download workspace
alirezasafaeigfx Oct 11, 2026
921d2e4
fix(ai): resolve image workspace lint and browser selectors
alirezasafaeigfx Oct 11, 2026
0f1f851
fix(ai): prevent pre-hydration image form input loss
alirezasafaeigfx Oct 11, 2026
8c5e34e
test(ai): type hydration assertion and scope quota alert
alirezasafaeigfx Oct 11, 2026
6a22752
fix(ai): close image quota minute-boundary overlap
alirezasafaeigfx Oct 11, 2026
9c4658a
feat(ai): add explicit one-shot provider acceptance gate
alirezasafaeigfx Oct 11, 2026
cc6ed78
test(ai): cover keyboard consent and narrow viewport
alirezasafaeigfx Oct 11, 2026
4b54b6d
fix(ai): explicitly project one-shot environment keys
alirezasafaeigfx Oct 11, 2026
be0b600
docs(ai): lock separate Workers Free image account setup
alirezasafaeigfx Oct 11, 2026
f6f6941
docs(ai): record isolated Free image account real inference acceptance
alirezasafaeigfx Oct 11, 2026
3dfa86c
docs(ai): confirm dashboard neuron usage for isolated image account
alirezasafaeigfx Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/ai-image-browser.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: AI image browser mock

on:
pull_request:
paths:
- 'app/ai/image/**'
- 'app/ai/page.tsx'
- 'app/api/ai/image/**'
- 'components/ai/ImageWorkspace*'
- 'lib/ai/image-quota*'
- 'tests/e2e/ai-image-enabled.spec.ts'
- '.github/workflows/ai-image-browser.yml'

permissions:
contents: read

concurrency:
group: ai-image-browser-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
isolated-image-browser:
runs-on: ubuntu-latest
timeout-minutes: 18
env:
# These values are synthetic and not real account credentials.
AI_IMAGE_E2E_ENABLED_MOCK: 'true'
FEATURE_AI_IMAGE_ENABLED: 'true'
AI_IMAGE_FREE_PLAN_CONFIRMED: 'true'
AI_IMAGE_ACCOUNT_ISOLATED_CONFIRMED: 'true'
AI_IMAGE_CLOUDFLARE_ACCOUNT_ID: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
AI_IMAGE_CLOUDFLARE_TOKEN: 'synthetic-image-token-no-access'
AI_CLOUDFLARE_ACCOUNT_ID: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
AI_CLOUDFLARE_TOKEN: 'synthetic-chat-token-no-access'
AI_VISITOR_SECRET: 'synthetic-test-only-visitor-signing-key-000001'
PLAYWRIGHT_SKIP_FIREFOX: '1'
PLAYWRIGHT_DISABLE_VIDEO: '1'
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 20
- name: Enable pnpm
run: |
corepack enable
corepack prepare pnpm@9.15.0 --activate
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- name: Browser-only mocked image, consent, preview, download and quota checks
run: pnpm exec playwright test tests/e2e/ai-image-enabled.spec.ts --project=chromium --workers=1 --reporter=list
- uses: actions/upload-artifact@v7
if: failure()
with:
name: ai-image-browser-mock
path: |
playwright-report/
test-results/
retention-days: 7
52 changes: 52 additions & 0 deletions .github/workflows/ai-image-quota-postgres.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Image quota PostgreSQL

on:
pull_request:
paths:
- 'app/api/ai/image/**'
- 'lib/ai/image-quota*'
- 'tests/integration/ai-image-quota-postgres.test.ts'
- '.github/workflows/ai-image-quota-postgres.yml'

permissions:
contents: read

jobs:
disposable-postgres:
runs-on: ubuntu-latest
timeout-minutes: 12
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: unit
POSTGRES_PASSWORD: unit
POSTGRES_DB: pt_image_test
ports:
- 55439:5432
options: >-
--health-cmd "pg_isready -U unit -d pt_image_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
PG_TEST_USER: unit
PG_TEST_PASSWORD: unit
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 20
- name: Enable pnpm
run: |
corepack enable
corepack prepare pnpm@9.15.0 --activate
- run: pnpm install --frozen-lockfile
- name: Prove atomic image quotas against disposable PostgreSQL
run: |
# Compose the disposable connection string at runtime, never commit a credential-bearing URL.
TEST_IMAGE_DATABASE_URL="$(node -e 'const u=new URL("postgres://127.0.0.1:55439/pt_image_test");u.username=process.env.PG_TEST_USER;u.password=process.env.PG_TEST_PASSWORD;process.stdout.write(u.toString())')"
export TEST_IMAGE_DATABASE_URL
pnpm exec vitest run tests/integration/ai-image-quota-postgres.test.ts --reporter=dot
21 changes: 21 additions & 0 deletions app/ai/image/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import ImageWorkspace from '@/components/ai/ImageWorkspace';
import SiteShell from '@/components/ui/SiteShell';
import { imageGenerationReady } from '@/lib/ai/image-quota';
import { buildMetadata } from '@/lib/seo';

// The image page is intentionally not indexable until explicitly approved for release.
export const dynamic = 'force-dynamic';
export const metadata = buildMetadata({
title: 'ساخت تصویر با هوش مصنوعی | جعبه ابزار فارسی',
description: 'محیط آزمایشی ساخت تصویر با توضیح فارسی، رضایت صریح و سهمیه محدود.',
path: '/ai/image',
robots: { index: false, follow: false },
});

export default function AiImagePage() {
return (
<SiteShell containerClassName="py-0">
<ImageWorkspace enabled={imageGenerationReady()} />
</SiteShell>
);
}
17 changes: 14 additions & 3 deletions app/ai/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import Link from 'next/link';
import SiteShell from '@/components/ui/SiteShell';
import { aiFeatureEnabled } from '@/lib/ai/quota';
import { imageGenerationReady } from '@/lib/ai/image-quota';
import { buildMetadata } from '@/lib/seo';

const enabled = aiFeatureEnabled();
const imageEnabled = imageGenerationReady();

export const metadata = buildMetadata({
title: 'ابزارهای هوش مصنوعی فارسی | جعبه ابزار فارسی',
Expand Down Expand Up @@ -49,15 +51,24 @@ export default function AiHubPage() {
</Link>
<div className="rounded-3xl border border-dashed border-(--border-light) bg-(--surface-2) p-7">
<span className="inline-block rounded-full border border-(--border-light) px-3 py-1 text-xs font-bold text-(--text-muted)">
هنوز در دسترس نیست
{imageEnabled ? 'ساخت تصویر · آزمایشی' : 'هنوز در دسترس نیست'}
</span>
<h2 className="mt-6 text-2xl font-black text-(--text-primary)">
ساخت تصویر با هوش مصنوعی
</h2>
<p className="mt-3 text-sm leading-8 text-(--text-secondary)">
تولید تصویر در حال بررسی است و فعلاً فعال نیست. پس از تأیید دسترسی و آزمایش خروجی
واقعی در دسترس قرار می‌گیرد.
{imageEnabled
? 'توضیح تصویرت را فارسی بنویس، رضایت ارسال را تأیید کن و نتیجه را در همین صفحه ببین.'
: 'تولید تصویر در حال بررسی است و فعلاً فعال نیست. پس از تأیید دسترسی و آزمایش خروجی واقعی در دسترس قرار می‌گیرد.'}
</p>
{imageEnabled ? (
<Link
href="/ai/image"
className="mt-6 inline-block text-sm font-bold text-primary hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
ورود به تصویرساز ←
</Link>
) : null}
</div>
</div>
<p className="text-xs leading-7 text-(--text-muted)">
Expand Down
144 changes: 144 additions & 0 deletions app/api/ai/image/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { NextRequest } from 'next/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mock = vi.hoisted(() => ({
ready: false,
quota: vi.fn(),
generate: vi.fn(),
visitor: vi.fn(),
}));
vi.mock('@/lib/ai/image-quota', () => ({
imageGenerationReady: () => mock.ready,
consumeImageQuota: mock.quota,
ImageQuotaExceeded: class ImageQuotaExceeded extends Error {},
}));
vi.mock('@/lib/ai/quota', () => ({
AI_VISITOR_COOKIE_NAME: 'pt_ai_v',
getOrCreateAiVisitor: mock.visitor,
}));
vi.mock('@/lib/ai/providers/cloudflare-image', () => ({
generateCloudflareImage: mock.generate,
ImageProviderError: class ImageProviderError extends Error {
constructor(readonly code: string) {
super(code);
}
},
}));
import { ImageQuotaExceeded } from '@/lib/ai/image-quota';
import { ImageProviderError } from '@/lib/ai/providers/cloudflare-image';
import { POST } from './route';

const payload = JSON.stringify({ prompt: 'یک منظره از کوهستان', consent: true });
const url = 'http://localhost:3100/api/ai/image';
function request(body = payload, headers: Record<string, string> = {}) {
return new NextRequest(url, {
method: 'POST',
body,
headers: { origin: 'http://localhost:3100', 'content-type': 'application/json', ...headers },
});
}

beforeEach(() => {
mock.ready = false;
mock.quota.mockReset().mockResolvedValue(undefined);
mock.generate.mockReset().mockResolvedValue({
image: Buffer.from([255, 216, 255, 217]),
mime: 'image/jpeg',
width: 8,
height: 8,
});
mock.visitor.mockReset().mockReturnValue({
visitor: 'synthetic-visitor',
cookie: 'signed-cookie',
renewed: true,
});
vi.stubEnv('AI_IMAGE_CLOUDFLARE_ACCOUNT_ID', 'a'.repeat(32));
vi.stubEnv('AI_IMAGE_CLOUDFLARE_TOKEN', 'synthetic-provider-token');
vi.stubEnv('AI_IMAGE_FREE_PLAN_CONFIRMED', 'true');
});
afterEach(() => vi.unstubAllEnvs());

describe('Image HTTP admission contract (mock-only, default off)', () => {
it('refuses calls while disabled without looking at payload or spending quota', async () => {
const response = await POST(request());
expect(response.status).toBe(503);
expect(response.headers.get('cache-control')).toBe('no-store');
expect(mock.quota).not.toHaveBeenCalled();
expect(mock.generate).not.toHaveBeenCalled();
});

it.each([
['foreign origin', payload, { origin: 'https://bad.invalid' }, 403],
['missing origin', payload, { origin: '' }, 403],
['missing consent', JSON.stringify({ prompt: 'گل' }), {}, 400],
['false consent', JSON.stringify({ prompt: 'گل', consent: false }), {}, 400],
['blank prompt', JSON.stringify({ prompt: ' ', consent: true }), {}, 400],
['oversized prompt', JSON.stringify({ prompt: 'x'.repeat(2049), consent: true }), {}, 400],
['bad JSON', '{', {}, 400],
['wrong content-type', payload, { 'content-type': 'text/plain' }, 415],
['oversized body', 'x'.repeat(5000), {}, 400],
])('blocks %s before spending allowance', async (_case, body, headers, status) => {
mock.ready = true;
const result = await POST(request(body, headers as Record<string, string>));
expect(result.status).toBe(status);
expect(mock.quota).not.toHaveBeenCalled();
expect(mock.generate).not.toHaveBeenCalled();
});

it('executes quota before the one permitted provider request, returns a private download', async () => {
mock.ready = true;
const result = await POST(request());
expect(result.status).toBe(200);
expect(result.headers.get('content-type')).toBe('image/jpeg');
expect(result.headers.get('content-disposition')).toContain('attachment;');
expect(result.headers.get('cache-control')).toBe('no-store');
expect(result.headers.get('x-content-type-options')).toBe('nosniff');
expect(result.headers.get('set-cookie')).toContain('HttpOnly');
expect(Buffer.from(await result.arrayBuffer())).toEqual(Buffer.from([255, 216, 255, 217]));
expect(mock.quota).toHaveBeenCalledOnce();
expect(mock.generate).toHaveBeenCalledOnce();
expect(mock.quota.mock.invocationCallOrder[0]).toBeLessThan(
mock.generate.mock.invocationCallOrder[0]!,

Check warning on line 101 in app/api/ai/image/route.test.ts

View workflow job for this annotation

GitHub Actions / quality

Forbidden non-null assertion
);
expect(mock.generate.mock.calls[0]?.[0]).toBe('یک منظره از کوهستان');
});

it('fails closed on quota exhaustion without contacting the provider', async () => {
mock.ready = true;
mock.quota.mockRejectedValue(new ImageQuotaExceeded());
const res = await POST(request());
expect(res.status).toBe(429);
expect(mock.generate).not.toHaveBeenCalled();
});

it('fails closed on database failures without exposing the error text', async () => {
mock.ready = true;
mock.quota.mockRejectedValue(new Error('private database connection credentials'));
const res = await POST(request());
expect(res.status).toBe(503);
expect(await res.text()).not.toContain('private database');
expect(mock.generate).not.toHaveBeenCalled();
});

it.each([
['throttled', 429],
['unavailable', 503],
['invalid_response', 503],
] as const)('returns safe %s status when provider fails', async (code, status) => {
mock.ready = true;
mock.generate.mockRejectedValue(new ImageProviderError(code));
const res = await POST(request());
expect(res.status).toBe(status);
expect((await res.text()).toLowerCase()).not.toContain('token');
expect(mock.generate).toHaveBeenCalledOnce();
});

it('fails closed when credentials disappear before inference', async () => {
mock.ready = true;
vi.stubEnv('AI_IMAGE_CLOUDFLARE_TOKEN', '');
const res = await POST(request());
expect(res.status).toBe(503);
expect(mock.quota).not.toHaveBeenCalled();
expect(mock.generate).not.toHaveBeenCalled();
});
});
Loading
Loading