1
0
mirror of git://f0xx.org/ac/ac-docs synced 2026-07-29 05:39:40 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:20:43 +02:00
commit 69a448f156
151 changed files with 33372 additions and 0 deletions

View File

@@ -0,0 +1,548 @@
# URL shortener service — specification
<!-- doc-meta:start -->
| Field | Value |
|---|---|
| Author | Anton Afanasyeu |
| Revision | R1 |
| Creation date | 2026-06-11 |
| Last modification date | 2026-06-11 |
| Co-authored | |
| Severity | medium |
| State | scheduled |
| Document type | spec |
<!-- doc-meta:end -->
\newpage
\newpage
---
**Document type:** SPEC
**Source draft:** [docs/drafts/20100611_3_url_shortener.txt](../drafts/20100611_3_url_shortener.txt)
**Design review:** [docs/DRs/20100611_3_url_shortener.md](../DRs/20100611_3_url_shortener.md)
**Status:** Post-alpha (next after alpha); priority may shift with BE capacity/load
**Severity:** Medium
**Public host:** `https://s.f0xx.org`
**Repo placement:** `backend/url-shortener` submodule (git → `git://f0xx.org/androidcast_project/url-shortener`)
**Scaffold:** [backend/url-shortener/](../../backend/url-shortener/) · **OpenAPI:** [openapi.yaml](../../backend/url-shortener/openapi.yaml)
**Documentation index:** [README.md](../README.md)
---
---
---
---
---
---
---
---
## Table of contents
<!-- toc -->
- [1. Purpose](#1-purpose)
- [2. Scope](#2-scope)
- [3. Requirements](#3-requirements)
- [3.1 Must](#31-must)
- [3.2 Should](#32-should)
- [4. Architecture](#4-architecture)
- [5. Authentication and trust](#5-authentication-and-trust)
- [6. REST API](#6-rest-api)
- [6.1 Shorten — GET](#61-shorten-get)
- [6.2 Shorten — POST](#62-shorten-post)
- [6.3 Error response](#63-error-response)
- [6.4 Success response](#64-success-response)
- [6.5 QR](#65-qr)
- [7. Redirect and permalink behaviour](#7-redirect-and-permalink-behaviour)
- [8. Slug algorithm](#8-slug-algorithm)
- [9. Database schema](#9-database-schema)
- [9.1 links](#91-links)
- [9.2 bearer_tokens](#92-bearertokens)
- [9.3 signer_keys](#93-signerkeys)
- [9.4 audit_log](#94-auditlog)
- [10. QR codes](#10-qr-codes)
- [11. OpenAPI](#11-openapi)
- [12. Deployment](#12-deployment)
- [13. Security](#13-security)
- [14. Observability and limits](#14-observability-and-limits)
- [15. Tests](#15-tests)
- [16. Out of scope (v1)](#16-out-of-scope-v1)
- [17. Related docs](#17-related-docs)
- [18. Routing (normative)](#18-routing-normative)
- [19. Appendix A — curl cookbook](#19-appendix-a-curl-cookbook)
- [20. Admin UI](#20-admin-ui)
<!-- /toc -->
**Documentation index:** [README.md](README.md)
---
## 1. Purpose
Provide **short, encoded URLs on demand** for long targets. The service is a **helper** for other apps and operators (Android Cast BE console, curl scripts, future mobile share flows) — not a user-facing product on its own.
**High-level flow:**
1. A client (any HTTP app; Android Cast not required) requests shortening of a long URL.
2. The client authenticates with a **project bearer token** (and optionally a **signer** for elevated TTL).
3. The service validates trust, creates or returns a cached short link, and responds with JSON.
4. End users open `https://s.f0xx.org/{slug}` → HTTP redirect to the original URL (while TTL valid).
---
## 2. Scope
| In scope (v1) | Out of scope (v1) |
|---------------|-------------------|
| Standalone opt-in service (not inside crash-reporter PHP app) | Alpha release blocker |
| REST API + OpenAPI 3 document | Custom domains per tenant |
| PHP + nginx + MariaDB stack | Click analytics dashboard |
| Permalink reuse for same `(bearer, url)` | Link preview / OG scraper |
| Time-limited and permanent links (signer-gated) | Federated multi-region |
| QR code PNG for any short URL | Mobile SDK (HTTP only v1) |
**Scheduling:** Post-alpha by default. PO may reorder if BE load is low and another product track needs short links urgently (see DR §Estimates).
---
## 3. Requirements
### 3.1 Must
| ID | Requirement |
|----|-------------|
| R1 | Standalone service; development **separate** from main BE crash/tickets app |
| R2 | REST API documented with **OpenAPI 3** (`openapi.yaml` in repo) |
| R3 | Stack: **PHP 8.x**, **nginx**, **MariaDB**; OSS dependencies only (MIT/BSD/Apache) |
| R4 | Project path: `backend/url-shortener` submodule |
| R5 | **QR code** generation (PNG) for shortened URLs |
| R6 | **Permalink** semantics: same bearer + same normalized URL → same slug while record valid |
| R7 | **TTL**: temporary links expire; `ttl=0` permanent only for trusted signers |
| R8 | JSON request/response contract (§6) |
| R9 | Reverse lookup: `GET /{slug}``302` to stored origin (or `410` when expired) |
### 3.2 Should
| ID | Requirement |
|----|-------------|
| S1 | Support **GET** (query) and **POST** (JSON body) shorten endpoints |
| S2 | Rate limits per bearer token |
| S3 | Audit log table for create/access/deny |
| S4 | Admin token mint/revoke via hub **Short links** card + shared `ac_crash_sess` RBAC — [§20](#20-admin-ui) |
| S5 | Unit tests (PHP) + curl-based smoke script in `scripts/` |
---
## 4. Architecture
```text
Client (curl, BE job, future app)
│ HTTPS shorten API
┌─────────────────────────────────────┐
│ FE — Gentoo (s.f0xx.org TLS) │
│ nginx reverse_proxy │
└──────────────┬──────────────────────┘
│ proxy_pass → BE :80
┌─────────────────────────────────────┐
│ BE — Alpine │
│ nginx → php-fpm (url-shortener) │
│ MariaDB (url_shortener schema) │
└─────────────────────────────────────┘
Browser / QR scan
│ GET https://s.f0xx.org/{slug}
same path → lookup → 302 Location: origin
```
- **Not** mounted under `/app/androidcast_project/crashes/` — dedicated vhost `s.f0xx.org` (DR §Deployment).
- **Opt-in deploy:** absent from default BE sync until operator enables submodule + nginx fragment.
---
## 5. Authentication and trust
Normative decisions: [DR §Authentication and trust](../DRs/20100611_3_url_shortener.md#authentication-and-trust).
| Concept | Spec |
|---------|------|
| **bearer** | Project-scoped API token (`varchar(128)`), stored hashed in DB |
| **signer** | Optional second secret proving **elevated** rights (permanent TTL, high quotas) |
| **demo token** | Pre-seeded read-only or low-quota token for smoke tests |
| **App-wide session** | Human operators mint/revoke bearer tokens via BE console using existing `ac_crash_sess` RBAC — not used for machine `curl` shorten calls |
**Trust checks (shorten):**
1. `bearer` required → `401` if missing/unknown/revoked.
2. Lookup existing row for `(bearer_id, normalized_origin)`:
- If found and not expired → return cached short URL; `ttl` in response = **remaining seconds**.
- If found but expired → `409` / error code `TTL_EXPIRED` (do not extend without new shorten).
3. If not found → require **whitelist/trusted** bearer for create.
4. If `ttl=0` (permanent) → require valid **signer** with `can_permanent` capability.
5. If `ttl>0` → bearer must have `can_temporary` (default for trusted tokens).
---
## 6. REST API
Base: `https://s.f0xx.org/api/v1`
### 6.1 Shorten — GET
```http
GET /api/v1/shorten?url={URL_ENCODED}&bearer={TOKEN}&signer={SIGNER}&ttl={TTL}
```
| Param | Required | Description |
|-------|----------|-------------|
| `url` | yes | URL-encoded long URL (max 2048 chars after decode) |
| `bearer` | yes | API token |
| `signer` | if `ttl=0` | Signer secret for permanent links |
| `ttl` | no | Seconds until expiry; `0` = permanent (signer required); default `86400` |
### 6.2 Shorten — POST
```http
POST /api/v1/shorten
Content-Type: application/json
{
"url": "https://defender.net/very/long/link#with_anchors&param1=value1",
"bearer": "4abef433d23ab5x&&83^c6",
"signer": "optional-signer-key",
"ttl": 3600
}
```
Prefer POST when URL length or sensitive query strings make GET impractical.
### 6.3 Error response
```json
{
"code": "ACCESS_DENIED",
"url": "https://defender.net/...",
"bearer": "4abef…",
"desc": "access denied"
}
```
| `code` | HTTP | Meaning |
|--------|------|---------|
| `0` | 200 | Success (success body below) |
| `ACCESS_DENIED` | 403 | Bearer not trusted / revoked |
| `MISSING_BEARER` | 401 | No bearer |
| `INVALID_URL` | 400 | Malformed or disallowed scheme |
| `TTL_EXPIRED` | 409 | Cached link expired |
| `SIGNER_REQUIRED` | 403 | `ttl=0` without valid signer |
| `RATE_LIMITED` | 429 | Quota exceeded |
| `INTERNAL` | 500 | Server error |
### 6.4 Success response
```json
{
"code": "0",
"url": "https://defender.net/very/long/link#with_anchors&param1=value1",
"u": "https://s.f0xx.org/a1b2c3d4e5f6",
"bearer": "4abef433d23ab5x&&83^c6",
"ttl": "3542"
}
```
| Field | Description |
|-------|-------------|
| `url` | Original URL (normalized canonical form) |
| `u` | Short URL |
| `bearer` | Echo of bearer (truncated in logs only) |
| `ttl` | Remaining seconds; `"0"` if permanent |
Optional fields (v1.1 suggestion in DR): `qr` — URL to QR PNG.
### 6.5 QR
```http
GET /api/v1/qr/{slug}.png?size=256
```
Returns `image/png` encoding `https://s.f0xx.org/{slug}`. `404` if slug unknown; `410` if expired.
---
## 7. Redirect and permalink behaviour
| Request | Behaviour |
|---------|-----------|
| `GET /{slug}` | `302 Found``Location: origin`; update `accessed_at` |
| Expired slug | `410 Gone` HTML or JSON if `Accept: application/json` |
| Unknown slug | `404 Not Found` |
| `HEAD /{slug}` | Same as GET without body |
**Permalink:** For a given `(bearer, normalized_origin)`, the **same slug** is returned on repeat shorten while the row is valid. New slug only after expiry + delete or explicit admin purge.
**URL normalization (before hash/store):**
- Trim whitespace
- Lowercase scheme and host
- Preserve path, query, fragment
- Reject non-`http`/`https` schemes
---
## 8. Slug algorithm
**Normative (per DR):** SHA-256 truncated — **not** Java `String.hashCode` or character interleaving from the draft.
```
normalized = normalize_url(url)
material = bearer_id + "\x1f" + normalized
digest = SHA256(material)
slug = lowercase_hex(digest[0:6]) // 12 hex chars → ~48 bits
short_url = "https://s.f0xx.org/" + slug
```
**Collision handling:** On insert conflict on `slug`, append `1``f` suffix (max 4 chars) or re-hash with salt counter — see DR §Slug algorithm.
**Reverse lookup:** Primary key lookup on `slug` column (indexed unique). No recomputation at redirect time.
_Draft note preserved in DR §Slug algorithm — interleave + Java hash rejected for production._
---
## 9. Database schema
Database: `url_shortener` (separate schema or DB — DR §Data store).
### 9.1 `links`
| Column | Type | Notes |
|--------|------|-------|
| `id` | `BIGINT UNSIGNED AUTO_INCREMENT` | PK |
| `bearer_id` | `INT UNSIGNED` | FK → `bearer_tokens.id` |
| `signer_id` | `INT UNSIGNED NULL` | FK → `signer_keys.id` if used |
| `slug` | `VARCHAR(16)` | Unique, indexed |
| `origin` | `TEXT` | Normalized original URL |
| `origin_hash` | `CHAR(64)` | SHA-256 of origin (dedup aid) |
| `ttl_seconds` | `INT UNSIGNED` | `0` = permanent |
| `expires_at` | `DATETIME NULL` | NULL when permanent |
| `created_at` | `DATETIME` | |
| `updated_at` | `DATETIME` | |
| `accessed_at` | `DATETIME NULL` | Last redirect |
| `access_count` | `BIGINT UNSIGNED` | Default 0 |
Unique index: `(bearer_id, origin_hash)` where `expires_at IS NULL OR expires_at > NOW()` — implementation via application check + partial unique strategy per MariaDB version (DR).
### 9.2 `bearer_tokens`
| Column | Type | Notes |
|--------|------|-------|
| `id` | `INT UNSIGNED AUTO_INCREMENT` | |
| `token_hash` | `CHAR(64)` | SHA-256 of bearer secret |
| `label` | `VARCHAR(128)` | Operator label |
| `trusted` | `TINYINT(1)` | Whitelist flag |
| `can_temporary` | `TINYINT(1)` | Default 1 for trusted |
| `can_permanent` | `TINYINT(1)` | Default 0; needs signer anyway |
| `rate_limit_per_hour` | `INT UNSIGNED` | Default 1000 |
| `revoked_at` | `DATETIME NULL` | |
| `created_at` | `DATETIME` | |
### 9.3 `signer_keys`
| Column | Type | Notes |
|--------|------|-------|
| `id` | `INT UNSIGNED AUTO_INCREMENT` | |
| `key_hash` | `CHAR(64)` | SHA-256 of signer secret |
| `label` | `VARCHAR(128)` | |
| `can_permanent` | `TINYINT(1)` | |
| `revoked_at` | `DATETIME NULL` | |
### 9.4 `audit_log`
| Column | Type | Notes |
|--------|------|-------|
| `id` | `BIGINT UNSIGNED AUTO_INCREMENT` | |
| `event` | `VARCHAR(32)` | `shorten`, `redirect`, `deny`, … |
| `bearer_id` | `INT UNSIGNED NULL` | |
| `slug` | `VARCHAR(16) NULL` | |
| `ip` | `VARCHAR(45)` | |
| `detail` | `TEXT` | |
| `created_at` | `DATETIME` | |
---
## 10. QR codes
- Library: **endroid/qr-code** (MIT) or pure-PHP equivalent — license entry required in BE licenses file before merge.
- Default size: 256×256 PNG, ECC level M.
- Content: full short URL string (`https://s.f0xx.org/{slug}`).
- Cache: optional filesystem cache keyed by slug+size (DR suggestion).
---
## 11. OpenAPI
Ship [backend/url-shortener/openapi.yaml](../../backend/url-shortener/openapi.yaml) covering:
- `POST /api/v1/shorten`
- `GET /api/v1/shorten` (documented; mark `POST` preferred)
- `GET /api/v1/qr/{slug}.png`
- `GET /{slug}` redirect (document as `GET /r/{slug}` if internal router separates API — DR §Routing)
Serve docs at `GET /api/v1/docs` (Swagger UI static bundle or Redoc) — opt-in on staging only.
---
## 12. Deployment
| Item | Value |
|------|-------|
| Submodule path | `backend/url-shortener` |
| Remote | `git://f0xx.org/androidcast_project/url-shortener` (live submodule) |
| FE TLS | `/etc/letsencrypt/live/s.f0xx.org/` (live on FE) |
| BE docroot | `/var/www/localhost/htdocs/apps/s/` |
| nginx | [deploy/nginx.fe-s.f0xx.org.snippet](../../backend/url-shortener/deploy/nginx.fe-s.f0xx.org.snippet), [deploy/nginx.be-url-shortener.fragment](../../backend/url-shortener/deploy/nginx.be-url-shortener.fragment) |
| PHP-FPM | Same pool as other BE apps or dedicated pool if load warrants |
| MariaDB | Schema `url_shortener` |
| Enable | Operator copies submodule, runs migration, includes nginx fragment, reload |
**Not required** for alpha BE validation script `validate_be_services.sh` until service is enabled.
---
## 13. Security
| Topic | Rule |
|-------|------|
| Secrets at rest | Store only hashes of bearer/signer |
| TLS | Required on public FE |
| SSRF | Block shorten targets to `127.0.0.0/8`, `10.0.0.0/8`, `169.254.0.0/16`, `metadata` hosts |
| Open redirect | Only stored origins issued by trusted bearers |
| Logging | Never log full bearer/signer; truncate to 6 chars |
| CSRF | N/A for token-based API; admin UI uses session + CSRF token |
Optional HMAC request signing for GET — DR §Suggestions.
---
## 14. Observability and limits
| Limit | Default |
|-------|---------|
| Max URL length | 2048 |
| Max slug length | 16 |
| Rate limit | 1000 shorten/hour per bearer |
| Audit retention | 90 days (cron purge script) |
Metrics (suggestion): redirect count, 4xx/5xx rate — post-v1 Prometheus or nginx logs.
---
## 15. Tests
| Layer | Location |
|-------|----------|
| PHP unit | `backend/url-shortener/tests/` — normalize, slug, TTL math, auth |
| Smoke | `backend/url-shortener/scripts/smoke_shorten.sh` |
| BE validation | Optional line in `validate_be_services.sh` when deployed |
---
## 16. Out of scope (v1)
- Link editing after creation
- Custom slugs chosen by user
- Analytics UI
- Integration inside Android APK (use HTTP from BE or scripts first)
---
## 17. Related docs
- [DR — design review](../DRs/20100611_3_url_shortener.md)
- [backend/url-shortener/README.md](../../backend/url-shortener/README.md) — scaffold + deploy
- [INFRA.md](../INFRA.md) — FE → BE proxy
- [20260610_SERVICES.md](../20260610_SERVICES.md) — service catalog (preview row)
- [ROADMAP.md](../ROADMAP.md) — post-alpha track
---
## 18. Routing (normative)
Single vhost `s.f0xx.org`. **API and redirect share the host**; paths are disjoint:
```text
https://s.f0xx.org
/api/v1/health → JSON health
/api/v1/shorten → shorten (GET query or POST JSON)
/api/v1/qr/{slug}.png → QR PNG
/{slug} → 302 redirect (regex: [a-f0-9]{6,16})
```
```text
Client nginx (FE TLS) nginx (BE) PHP
│ POST /api/v1/shorten proxy_pass :80 /api/* → index.php
├──────────────────────────►──────────────────────────►──────────────────► shorten handler
│ GET /{slug}
├──────────────────────────►──────────────────────────► slug regex ──────► redirect handler
```
FE: full URI proxied to BE `:80` with `Host: s.f0xx.org` (see deploy snippet).
BE: `try_files``public/index.php` for `/api/` and slug paths.
---
## 19. Appendix A — curl cookbook
Runnable script: [backend/url-shortener/scripts/demo_curl.sh](../../backend/url-shortener/scripts/demo_curl.sh)
```bash
export BASE=https://s.f0xx.org
export BEARER=your-trusted-token
export TARGET='https://defender.net/very/long/link?demo=1'
# 1) Health
curl -fsS "$BASE/api/v1/health"
# 2) Shorten (POST)
curl -fsS -X POST "$BASE/api/v1/shorten" \
-H 'Content-Type: application/json' \
-d '{"url":"'"$TARGET"'","bearer":"'"$BEARER"'","ttl":3600}'
# 3) Follow redirect (HEAD)
SHORT=https://s.f0xx.org/a1b2c3d4e5f6 # from .u in response
curl -sSI "$SHORT" | grep -E '^(HTTP|Location:)'
# 4) QR PNG
curl -fsS -o /tmp/qr.png "$BASE/api/v1/qr/a1b2c3d4e5f6.png?size=256"
```
Deploy smoke: [scripts/smoke_shorten.sh](../../backend/url-shortener/scripts/smoke_shorten.sh)
---
## 20. Admin UI
**Decision (DR rev. 2):** operator token management lives in the **hub** as a **Short links** nav card (same theme as Remote access / Builder), not a separate skin.
| Action | Auth |
|--------|------|
| Mint / revoke bearer tokens | `ac_crash_sess` + RBAC `admin` / `operator` |
| List links per bearer | same |
| Purge expired | `admin` |
Machine clients (`curl`, BE jobs) use **bearer tokens only** — no session cookie on `/api/v1/shorten`.

View File

@@ -0,0 +1,659 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 35 0 R /F4 36 0 R /F5 37 0 R /F6 39 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 762.0394 84.37304 773.0394 ] /Subtype /Link /Type /Annot
>>
endobj
5 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 751.0394 77.26104 762.0394 ] /Subtype /Link /Type /Annot
>>
endobj
6 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 740.0394 104.373 751.0394 ] /Subtype /Link /Type /Annot
>>
endobj
7 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 59.68504 729.6019 88.44754 739.6019 ] /Subtype /Link /Type /Annot
>>
endobj
8 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 59.68504 719.6019 95.54254 729.6019 ] /Subtype /Link /Type /Annot
>>
endobj
9 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 709.0394 97.26104 720.0394 ] /Subtype /Link /Type /Annot
>>
endobj
10 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 698.0394 138.629 709.0394 ] /Subtype /Link /Type /Annot
>>
endobj
11 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 687.0394 91.03704 698.0394 ] /Subtype /Link /Type /Annot
>>
endobj
12 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 59.68504 676.6019 125.55 686.6019 ] /Subtype /Link /Type /Annot
>>
endobj
13 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 59.68504 666.6019 130.5525 676.6019 ] /Subtype /Link /Type /Annot
>>
endobj
14 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 59.68504 656.6019 121.7925 666.6019 ] /Subtype /Link /Type /Annot
>>
endobj
15 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 59.68504 646.6019 133.47 656.6019 ] /Subtype /Link /Type /Annot
>>
endobj
16 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 59.68504 636.6019 83.44504 646.6019 ] /Subtype /Link /Type /Annot
>>
endobj
17 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 626.0394 174.181 637.0394 ] /Subtype /Link /Type /Annot
>>
endobj
18 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 615.0394 105.709 626.0394 ] /Subtype /Link /Type /Annot
>>
endobj
19 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 604.0394 119.053 615.0394 ] /Subtype /Link /Type /Annot
>>
endobj
20 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 59.68504 593.6019 87.19504 603.6019 ] /Subtype /Link /Type /Annot
>>
endobj
21 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 59.68504 583.6019 120.135 593.6019 ] /Subtype /Link /Type /Annot
>>
endobj
22 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 59.68504 573.6019 112.2075 583.6019 ] /Subtype /Link /Type /Annot
>>
endobj
23 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 59.68504 563.6019 102.63 573.6019 ] /Subtype /Link /Type /Annot
>>
endobj
24 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 553.0394 94.59704 564.0394 ] /Subtype /Link /Type /Annot
>>
endobj
25 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 542.0394 91.49304 553.0394 ] /Subtype /Link /Type /Annot
>>
endobj
26 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 531.0394 101.709 542.0394 ] /Subtype /Link /Type /Annot
>>
endobj
27 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 520.0394 87.92504 531.0394 ] /Subtype /Link /Type /Annot
>>
endobj
28 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 509.0394 141.269 520.0394 ] /Subtype /Link /Type /Annot
>>
endobj
29 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 498.0394 78.58904 509.0394 ] /Subtype /Link /Type /Annot
>>
endobj
30 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 487.0394 120.389 498.0394 ] /Subtype /Link /Type /Annot
>>
endobj
31 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 476.0394 105.717 487.0394 ] /Subtype /Link /Type /Annot
>>
endobj
32 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 465.0394 129.269 476.0394 ] /Subtype /Link /Type /Annot
>>
endobj
33 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 454.0394 161.741 465.0394 ] /Subtype /Link /Type /Annot
>>
endobj
34 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 45.68504 443.0394 91.92504 454.0394 ] /Subtype /Link /Type /Annot
>>
endobj
35 0 obj
<<
/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
36 0 obj
<<
/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font
>>
endobj
37 0 obj
<<
/BaseFont /Symbol /Name /F5 /Subtype /Type1 /Type /Font
>>
endobj
38 0 obj
<<
/Border [ 0 0 0 ] /Contents () /Dest [ 40 0 R /Fit ] /Rect [ 387.5218 95.58819 399.1978 103.9882 ] /Subtype /Link /Type /Annot
>>
endobj
39 0 obj
<<
/BaseFont /ZapfDingbats /Name /F6 /Subtype /Type1 /Type /Font
>>
endobj
40 0 obj
<<
/Contents 85 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 84 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
41 0 obj
<<
/Annots [ 4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R
14 0 R 15 0 R 16 0 R 17 0 R 18 0 R 19 0 R 20 0 R 21 0 R 22 0 R 23 0 R
24 0 R 25 0 R 26 0 R 27 0 R 28 0 R 29 0 R 30 0 R 31 0 R 32 0 R 33 0 R
34 0 R ] /Contents 86 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 84 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0
/Trans <<
>> /Type /Page
>>
endobj
42 0 obj
<<
/Contents 87 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 84 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
43 0 obj
<<
/Contents 88 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 84 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
44 0 obj
<<
/Annots [ 38 0 R ] /Contents 89 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 84 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0
/Trans <<
>> /Type /Page
>>
endobj
45 0 obj
<<
/Contents 90 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 84 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
46 0 obj
<<
/Contents 91 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 84 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
47 0 obj
<<
/Contents 92 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 84 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
48 0 obj
<<
/Contents 93 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 84 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
49 0 obj
<<
/Contents 94 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 84 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
50 0 obj
<<
/Outlines 52 0 R /PageMode /UseNone /Pages 84 0 R /Type /Catalog
>>
endobj
51 0 obj
<<
/Author (Android Cast project) /CreationDate (D:20260623115607+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260623115607+02'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (URL shortener service \204 specification) /Trapped /False
>>
endobj
52 0 obj
<<
/Count 34 /First 53 0 R /Last 83 0 R /Type /Outlines
>>
endobj
53 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 54 0 R /Parent 52 0 R /Title (1. Purpose)
>>
endobj
54 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 55 0 R /Parent 52 0 R /Prev 53 0 R /Title (2. Scope)
>>
endobj
55 0 obj
<<
/Count 2 /Dest [ 40 0 R /Fit ] /First 56 0 R /Last 57 0 R /Next 58 0 R /Parent 52 0 R
/Prev 54 0 R /Title (3. Requirements)
>>
endobj
56 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 57 0 R /Parent 55 0 R /Title (3.1 Must)
>>
endobj
57 0 obj
<<
/Dest [ 40 0 R /Fit ] /Parent 55 0 R /Prev 56 0 R /Title (3.2 Should)
>>
endobj
58 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 59 0 R /Parent 52 0 R /Prev 55 0 R /Title (4. Architecture)
>>
endobj
59 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 60 0 R /Parent 52 0 R /Prev 58 0 R /Title (5. Authentication and trust)
>>
endobj
60 0 obj
<<
/Count 5 /Dest [ 40 0 R /Fit ] /First 61 0 R /Last 65 0 R /Next 66 0 R /Parent 52 0 R
/Prev 59 0 R /Title (6. REST API)
>>
endobj
61 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 62 0 R /Parent 60 0 R /Title (6.1 Shorten \204 GET)
>>
endobj
62 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 63 0 R /Parent 60 0 R /Prev 61 0 R /Title (6.2 Shorten \204 POST)
>>
endobj
63 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 64 0 R /Parent 60 0 R /Prev 62 0 R /Title (6.3 Error response)
>>
endobj
64 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 65 0 R /Parent 60 0 R /Prev 63 0 R /Title (6.4 Success response)
>>
endobj
65 0 obj
<<
/Dest [ 40 0 R /Fit ] /Parent 60 0 R /Prev 64 0 R /Title (6.5 QR)
>>
endobj
66 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 67 0 R /Parent 52 0 R /Prev 60 0 R /Title (7. Redirect and permalink behaviour)
>>
endobj
67 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 68 0 R /Parent 52 0 R /Prev 66 0 R /Title (8. Slug algorithm)
>>
endobj
68 0 obj
<<
/Count 4 /Dest [ 40 0 R /Fit ] /First 69 0 R /Last 72 0 R /Next 73 0 R /Parent 52 0 R
/Prev 67 0 R /Title (9. Database schema)
>>
endobj
69 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 70 0 R /Parent 68 0 R /Title (9.1 links)
>>
endobj
70 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 71 0 R /Parent 68 0 R /Prev 69 0 R /Title (9.2 bearer_tokens)
>>
endobj
71 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 72 0 R /Parent 68 0 R /Prev 70 0 R /Title (9.3 signer_keys)
>>
endobj
72 0 obj
<<
/Dest [ 40 0 R /Fit ] /Parent 68 0 R /Prev 71 0 R /Title (9.4 audit_log)
>>
endobj
73 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 74 0 R /Parent 52 0 R /Prev 68 0 R /Title (10. QR codes)
>>
endobj
74 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 75 0 R /Parent 52 0 R /Prev 73 0 R /Title (11. OpenAPI)
>>
endobj
75 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 76 0 R /Parent 52 0 R /Prev 74 0 R /Title (12. Deployment)
>>
endobj
76 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 77 0 R /Parent 52 0 R /Prev 75 0 R /Title (13. Security)
>>
endobj
77 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 78 0 R /Parent 52 0 R /Prev 76 0 R /Title (14. Observability and limits)
>>
endobj
78 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 79 0 R /Parent 52 0 R /Prev 77 0 R /Title (15. Tests)
>>
endobj
79 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 80 0 R /Parent 52 0 R /Prev 78 0 R /Title (16. Out of scope \(v1\))
>>
endobj
80 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 81 0 R /Parent 52 0 R /Prev 79 0 R /Title (17. Related docs)
>>
endobj
81 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 82 0 R /Parent 52 0 R /Prev 80 0 R /Title (18. Routing \(normative\))
>>
endobj
82 0 obj
<<
/Dest [ 40 0 R /Fit ] /Next 83 0 R /Parent 52 0 R /Prev 81 0 R /Title (19. Appendix A \204 curl cookbook)
>>
endobj
83 0 obj
<<
/Dest [ 40 0 R /Fit ] /Parent 52 0 R /Prev 82 0 R /Title (20. Admin UI)
>>
endobj
84 0 obj
<<
/Count 10 /Kids [ 40 0 R 41 0 R 42 0 R 43 0 R 44 0 R 45 0 R 46 0 R 47 0 R 48 0 R 49 0 R ] /Type /Pages
>>
endobj
85 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 856
>>
stream
Gat=*9lldX&A@sBlnQW3MH6?g9[fdn*?U`;p65qpb6]$g0&SS^pQ]\O+13hJ(gDWgp\*jV.g^d\#L!-9%.H-)l[&bF&?Gj8&J31E/suR)i9*cWM=Mo,TX?,2qnh@Z0VKQJOos[WU=o^D-1_:,p,oUM\.>>XLi!D%-s[+u>QAl.OE*E(/E,U?isbLF1b27/1<<2`(+fqW0r?,OQpnB>(1@fOaX%(n&7!&YNONpMluP_'66^3eP+o,k\ntM(-ZURQSU,&ZFWIepSb&It\+"LE8b6C0XuA#m(Wa^U^;#jgH[`Q-PkR_6RP7O(;O\>aB2,kl.E;)A^$[6X`hd8?#t(Sk>7r[nX"2BXSn\b1LZX\]:dDLl;"QTNIh&_Ic)].K'rQ?Q=[r&TQ?C0^/+\Pp?W=Itmli$L]=L#DWj4'0cnEl:4u2Hcd7cl`6r6kG0`MX7>LR"uU0T5?/;8BP!b*tDc*jj!1ghnJ:a\]#^s+?&_Yh;fO&mCt6[WWg5FT&0Deqh^KqH2@4]:rd)f@H1m[:9fMsdK+TQbb`7[]]CAbY1*h.j;+ZS4$T:7(iPJ)CCJ7iJ`71#D\I)OB&n#1T[&h>LX[+2*iJGr&T&I%63USi\jRBH-\*KVKQV(PBHcGiF,@@[>bkO7CfWZ)$:*`ATVG!QE)kJEkP[mY5e5p4rU[:5HrtOS5^&Bck+EgC4#9+80]&(qJ6Ubf_0`*2'EOg6gN[EeMM>(7IUl-?D+tjq+XlILG5g(u9f-)PL=?<?jm>+_h-u+a?@U>?6GCTm%U$iV"qT=s&c1cP.k'd)2kgM&(nN6+?6OQTe9=#A,1k,\KMdhZG3^7.eXVjorW026W8O0_krP3e[h~>endstream
endobj
86 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1034
>>
stream
Gatn'bAQ&g&4Q?hMHM930Hrs!\oSI].+#h+XR6-,ZGbCs,uGg7Vtp4MA1)%3:$I0>//d%!g_l82=/HPe7`Tr1!.;oIr/q+mOP^-$KL>_ZVYS?i#sDP8,8-nO^`c8^&qh.CqsZEK;AM_FO>)TF,R'?!/E7H:q?3E^T.i*P]Kl``0Gk`7Vg5PI"fPWR<GP$=bo,TW+TF:&YAeiH*sWnGLO2.;FGu<AF@ortlV8>VJE<1P/0hAI<TaDm;d[n5^pU]<V&#&#T%WXMb(5]=&YBJ=Do\On"]5!A)s]bd^<$d!)qEQ*"$?;!m!^bNSA7OZ)eh`THgNN@E>JF\8'(,c#"Mj_KpAZlU924GVu5T3Q];nqh/ndsAH'i)RiKb&DqnimEB*A)$Ob`DQGD*dm[tctXb&];0qU%&A44.CKJ:q[C!a_=Wcb:d1&%f$_kJeW@E^GU6Jj7:\@QkA1@Aoq,X_+(YkFB+>_nrt9i9)W$S/J2G#J"e5n=EQkt/,^ebpDDHEFQ]S&U"#0OOI2^dV[i<FH#tE[.%4s7e!(*rk(BaKRQ2"5qN3UXV5kiNO,%kbV9`dCMk>HqOa]aE<CR[c5+l5jV\TLSCuJ?.NcXJ<-WfcPI-trTH0ji=\*DB/\.Sc8s?6[6f8D2_Nh/$dmMi@5*-?kRY2,J"b83/k"B&M0X%?qm0W("p;2s&UIN[A<$5dlIN8'0$]g\o3i>m=>#OGeXu0%BY.li*T!j@n=fqBV`T3Gk+Emff(/%WoBO2M$7]&e,Tug3hf&q?[Hal1f,/k8YhN?l'2C!4]_+:BNGd2'P-<qbR-`'>/qt:&O4pc+j.M-R.sqa5hkY!t$U5X=&4b1a-35.&DdS%_-p:gcb+aDu0nkt7=3;R'63t]I*=P0*\cl91;LAZacle,n?%u"reC*]CZd>MIp'ES>Tn`<0Z$F7#dTaMKjqjAgPN#n^"*RUF0R_R3Xa&+uh%hj`J.eJcaK*ZZ_C82gG^8(;J')4iikp6iIg$O?N$Mht!fKT;BfZHBb2=6AHNo(c58e\!KE~>endstream
endobj
87 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 140
>>
stream
Gar'`]aDVA&B+*fTAj4X`?fRI\P"GdAL$O\#RWm*IK./0>U9b60^r:$Y,=E)$_3dH.MVD#as#e(/oDa=2+r)O^V;GfW,6,V;^^n2rk5*EC76*HKHdgU0,tT,)NXY45AfCtfFMZc*Cg~>endstream
endobj
88 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 140
>>
stream
Gar'`YmS?5&;9"kTAhN!`?fT9b?\:;.iVpS5Yr"ikkc/CiMs+9!s@G<@6O#R>i=W62kc&B:^RNDF*7@`-qPm%a8WcSTt6XP9T?-Sg@m_L:r^$YF3%8(pd1>2d;Zrepj!M`_u=?S*Cp~>endstream
endobj
89 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3562
>>
stream
Gau0F=``=W&q9SYkXTo-;Zq>,[1?%!a^iC/4-J`#<A'/^,fUP7nF3\eqqnm!6_Z"!P-a8<fG("H7\eB0I16gNnV:ZT3ZETF1Dp-eJ5DUbJZ+_=ZTG[fSc(Yq`Cs9qFAnB'0J$M(Z>/=lcFtBk?ttj%9_`mfP0$`BPr+kFJB6aE*NGoC$B9*"'<PYa_jtneJcq7GOBnEplpkYi2JWHELYL/.B=$KKFl+,H0XRs9K+44=1iaGCOhFEuoXin8a4X9OVD=9FSWHA'0oc+OGg3":"Y>h"f;+1]](umaZ.N7a&@#l/]c9!%B$t/I`pI3-VS9s)M4LI4JRo?S:+gBJ>h^qS"me)IoH]>;qe1HX&]2WLOdE_s`dWPbm4AurnWGbW\cp+aD$Pj95.CEVY:?IIGKQMF0]QOAhjE`+Ri^8uj`KZKU^Q($@#`Gm0VEY[%GC>68](7;4H?87r;%o5B[t!Y8l,3*KS:C4hfbAHUg<+7D!-WYmMM_fEZ9H"n&4I"_qigT(_\DQ.:m2gG\P?7:,RJ"X4s>Z>*OV&.6TGp"#o52le$4J(e4E.F-9R^k3"mt8E`-1LLW!(gij:aGLTu0QrDfhf2r:th1mPkIe\43K&c$;kXf^d`p^/@fod%:-I')+eg4&<e<UF#SB5Bd"BdL&]ig&,R?FW_X_!)2&PKT:-;[:].;bYa>*;>a]k)dJTp-<-Qjq4F[J_F?Cc?)^2Hr:FGFM.mO@CCS=ndC/e7qsXlphdN8/i.jp0:W/PUqhIJ-?F,R1'$gmUG)T\86f\U)lHj*eDXi'dRt0@S&,eCs=)7dBeTcd>PiDMdJ8)4?V(Z%StY"[>,oS.r[pb7WuYD(m"K8INm`,*m#tBo-!0Z]-n[VEps2slpP0I`##XOP;HaQi<p1&7(ftu@HmBMG^bZcK,P;?X<`n%SX2u#&njM$F#S4SYu36k"ht1<&+:/>"0M3El.Mi0Hg?3!')KLMXY5)R*.DMS<=a5X=kW-OFnM8[;0<>.mg5pL+5+ILr%X[(#^:cO4nf<@7-nRO7Ee2MM*G.%/h-@LE#)fTpTeYVkigtQ%8SN8*i0+`miY;<*`IdWG)B-0,bAKn/C&&06&679)\ri@5(\GtNiWJUE.NhM`8cF9qeq6f"CaD5W!mkZe[mN)h9F:CXPYj0h`,3I9Kh.Ek@TWJU,9lBl9m1M7)L4'E?C!(kLe&u%F"MSHnT`UaNdc'ifFC_iOD+MpODqBhCq^^4]R)<<%O&5SCO'G6o+a;m&-oa2F)s>ANl#k$tB]W%#9K<`X_je>C!aA,'G[L?Ig;ji(iF+mC/^B.Oggc1hM53V:u:YZTj3Ae3QS^>+lb_:K<C3%S8RXU+%36Q7a+'8;9ScQ&%+%n8"=DcB9TL3a?-*&..kc'IeKg.JT_a;l!*W$ME;qQZT?.Y!u3KX&tp5EFcRaSC])HA$n.S9L97<XTGBTHA9U:T*_a-+T*-c?,HQY:jXN@8<GB$dUi@k(&)Dd;K@\aW[r-bRMVnA4g^EQG,0#,CL]50,+W%d_)>::A]%^2"gL<1:1VE2b6[>t,T,=S+'J!qj,i4H?"]*N_E;\U$l2#sPT2P1;BUaq6SqJSV_^Rt%3M5CegSNi=qTR`)QrQck'Fa-'cS)4$DFD6#i&XK@AK*\>/[RkJ,o3$/,oN[8"P/#$/u)%7e`X3X8@j_oa)"imD!'uO28dH=`?H;VX"Rco2m"G_QC*H))[r-U5^a>+@ZLbTr`pfp4h*Jd<q/\L'p7#fdAIH;u/0B:08+jD&=,-r>1#fmBAH-F.tHYbKS_F9AeKK!i(RW*srM]!^6oL#XFr5S'.(I0n#?KgkajU/+>W(YY"f3_iqi%W7A>]6T'7`J=n=[nFr8c:BdW?!=r6)*kO@1hm]+N9Q0ns#A2>7#7):gZML3gs,StXUkYl]W;iH:3CAo!;V(!TXt&Co&(>>[Y'r"lI4XjL<G!3eK8$uoJ5>8,(@TBG5*Z4PZ7?%P<"<;=Xpj5e3Ijnj!W9,@^`$Dp^o)R1$Jm7U5o@:`.#e/^KY(4]9=Enn>-:bG4/%9:+)/#'@SZHDXP2BiM`51`9,g5:=o*QtG)R9!0\4'E$>M'%NIppLV9@/U*TXlA,a_/VaV4frgpdF3A'?q`4J3NeGW6RnS&puNcDbR6`eh/:0Y\Df<@O'/VDWQF'[tc2(l1tMN_FJ:-q[e#D2b3)9tJQ#5+&0%973tINPIJ(I@Y&=LeEK)Q4>`FoV:&Y%pfmN$5Lr14n3)BcmH[n6*f$XZ?[/>G/uQ%A&dlW<DR$-8iJQ!ZrM5r(aiLZWjIi,Du.tp_."oBiah+EeA2l66@,_&BkF&5LoCI?$S;;iZedM0H'hmFD*'%(E/Ie3Mc.;aRmtdUM%Hp!]UH7p"TBJfb^3g2X>*$Kcj"o3+"jqh'&(5LV:bbaEf9ZbN/tIql_2g5YpBKp?d)"t[!C#BY>"Z.<F*[MJ/Kt+*__J=9"/`?A+7&c>+DLk\gIWG0W!CTdOsh619T"^W?<CmBcl;_3lSTs8%L6j4OF6u5dm__UMjLPj%n-nnT?86@BJrS#6Wb<g4RRaK?6a5]+"(;I5UA;bc"g.[B.prSnbY#eWs)P=%hk[/KfFh#qN85>I8#4g`X8%TBE#P=E8j>dDkA(40?PhVtV>$a0N_Ad5Y7cQ?jTgP.43uSG+M2U.)-Z6iIehltcXH=1[^F<Xdi#HWE>V"Dj-LBV[uOiJa.V0K!ch[^]'MSlL_ef-s5Y4ki(XS))VBFp/3,KKQQ8[fS=kf+<l.a8.5V')u<Zh;LF&S<5]A:0IU%1s%O+g_N\-G^mg$5sPZJ'b1'26=ub^nma"!_ZZqMf/eL5kj;qkE0FmCp#a[)MT/&s0nI@AS3Nk/6)OO"Mf>Y=%q*oT!W&=@RTN"UF`@_C38kIh6%1P=?GtKpI8;-o!VNa1gMY"ZO2U(LPl?XB5te4g=kEPY!d56<pB%@H"KeX5BhF)ZPC9UJCEWu+;B6sn-A&X<ZaJ7mmO9o8HAk#IN886u4XKAgLU,O1:j.g*]%<mR['dMB\tQCB5V,@09XFQ&!2<]P#q&PP$I^k.rT<`VP$'#.6i)pJe*+O";,=aVl"76i-\fYJkBh,]%/p4B$\qCR'spe=#%,ID@"nG3;V2=MVeIQ^DX,Rg'D3O!_#b@b+_VPfY<"8FT-!ep<nJ2C'jiFe*[&f=\_#DAg-N"FcKW.ULJ+0\qh;-3SmT_^]Qs"I^t,jtKc;Z;3d&+=9<i`$BAr#4,l)3ZA(u!**064i^&:iZ2!r,Na>*D<%P+^<%@6M-msoloPk9nFnEei4:)ES:SF.<"oq6DC7*?\CQ\W!eH\VlCk:u:94N?TVqKF<ZY!;)&)J(/<#s<.o;YaC#MbJIO[4\1si7EG[%$2`]%qmSo4eC+5+C;Xj>hH<scl:s*qunY!^8U"gim"Rp]AO8:\gj@,qLPF/]]7YC+pP)Y'pkl2H]gNsUm$!&Q9j+GKl)fo`@rR4rPa(_1<m9%<)gGu0^p,KQ/F]Dem?35nZpuec:Wg`#aW^2mp42]/m!"k7FA/Lf.]r/`A3!ZP(m@#K2s3+`^'6Y$Cq&*.q`QD~>endstream
endobj
90 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3217
>>
stream
Gb!#^>Ar9=&UsJX=JVl=T3;qQH'Joq_A=LGD43GM$M6W_YU3EeA)>G+A?/3!HY7U;8WaW3T;&`U.+I\1c?s[T"sY"7EUpU#+`ok5K9$jXJgPPl<CoWC05*1&Gs9't:0!L";j14WX+hlM6P[n;r:)CKo-!U;*&IJP7eB)D[RZB+5S4g!?oY9^M$K@I!V+Jr1;t!A)qSMIN2AaR)7eqoHA$_M&*IW?N0b,D1^p9pB5$i<9n#Ak"@(#Gi1jVTib)WMLa2QHA9'UuLbF?.Aj3f;qqQa+gdhn"Cu="7V`VRM3BiJMPRmqF!jI7Vi8W0iIXRK5kRhusVe7FV3<Yq+)"8UfrtRt=`?NT>@2-AW;=3Q&GJ=[4hE0BSTgYVX83,!,C=O>'U?O7B_.YJq\RJZfq=m1]Z0TWRY2%Co=^AYSS!J8&fYQL$Mm,DV4`C]5+T9O3=d46NIuW-re&\5lfBKEDi`="""nQEiNrNUE8<!("$=5\G2F-0gJNN/3-^oK&:!b)E6[ga,C]_uANP!BSTC3)fE/%;PTod9P_bIoUJu<1)pc1bikY#=2fEqH`'LWF5?nMWD^@0>":R)=j)^>/:DbsDiA/h,`=V,U(8Wt9_`TNR5j-kRl4D=l+fJ%>38@mIN)W\D>gO%!LQ9Jha[.J4^D'>L5X:?@g%]F;9JR_oKa!+^e?rfCo,0C^\&*hXWYIBqe!#.\?[0V"^-b<1Lf'2&Y4ciL">8Ffdg4Kek4d.?T2?QC4!m0=)W,H,"GC5K?n8V=gP<Nq_C!<?fK.V/UFCPpRptE1Wa4PB/&@.,36%kV$+'cet?Tr^c]lY.g;\kte&Ub?+G*5XeL.](OBVq3$5M)n]/;,Ar'qHVrA_ApI>qP(?Yi2CtMa6#FNg;fac^&0.FdHIi>cj.$<m._jeGtm`8j-h+!'n`dE&56oRJ,ApC.ClTToR&0;GFIU8<Q]ABF9oeTe792Opm<Y<@iCeiREs>,X[DrU8'nQTHHPl?\"mGc*eJ,VCb\4:.=j%m#\B:%KZ,0FB.u>\dC&1Jqk9#V!KG-Md`nkF">>M3u+$\.3gN?$gPO:=P.Ks6m7^Glc5).LWldG/KYc%dOpAo*dZWEUCiFX$^+HF-905%LJ`2e]/1Z(\HOU(Mlm'!_%ZGGdp=WmX_;5sBJf*b@V20%6*E414%C:M"cuiBaD)rU5.B.G*%OS/%L%DFO+%lV<4t/N[%RKY&j2GjLbgO]GD2*6`M*uO3WA%c\>I+uQ`WZ%W*t3!CG''+a?f=fW%UL(AFeh%/`?N#,^<W"1$+m*XoTG03><C$GsSI>rU=p'Nm#qGZgDhR+>&2o;S+%GmM]gC-oPE.M)^pF5l6JL#QI1Hj"]F7+d!;#n1"kD094:B2-`Q(rGn&J=-.W/_/Dun>*8Y@nf0k85bBDL8j+jCPbXL27D]13i;oam,mPqL1sB8H*gb(tG9CYFP-SRE7&\kcrUn]8>(-03$j6TH!N/GiL$1d:Ur7qZ:*H[N$T9R$\kP6\/,]3_,+6AG22pgmpRb2'_nG+K`nSFJG]M1k=cghabE]9mc+_VdAhoI>8dk-Q00fjo/\k<!!/[V\!)NCljHJ!n4J`F=$r^XI///U4$fB_(!nEM]X:LTIH:?A8;RU"PjZmRG_aK^j#b!"(8]6K[D:>hLr<8)j&/Y(!?+6S&`)>EK8m.ui*(K:?l%hT21_Eae93js!.&pXIb.(4&R=e^`i@fh[s5XD<Icb'^LH8=5bnBFLb"j;HE0BXfE):3R'/-Dk4jB(V3VGJC,K+WSq)>WmBj4QZIAonG'W7>CMX+U,J"?bCd,ZJ6UkfWL2*=o6OP?(`O":Ph5Tm<soT/l&^Xdsl@s:Rq5+LaY"?qPmlWm!`%DUp2ACEZ`CT"eCQl+'t4p%^=jh"@C1?N!cMudBb"SQ=/p/[!(bWd'FoBu5Kk]XR4["D&+i;#9WET0Ce]_^su(#ben<nKAq*%6sM8oToEFd*aiOeQ^s0+0.>0"BOVl$>sE;C6mZ982OpR0AjT(Kk>JGh6"1=gsCf;VkGK;7fQKhLioW@YS/YVGtBtmM+sD9(:?&'qEba=>ZfaNpFXL%_tC%GeB/P/\i[io_tA0Ck6g6Cmdk>+uM":C:iC)4C[H,5t%JjK0Np7$t3&%d:K.X-V%'hMPMY7hpB#%a2Nc8-PX.FmRQ;/q0gEr7T<YKS+#%6XD>u373/mm)'o`__$YBoL6+<*b`!DqQ[5#o'?aip1sTDbV6=Z(P7CZ'>[5tXKj)[c1bk]KA0#2Xoe0-"?bSh'RuP/=I(LETr]Eman+(>dm;Jc'5>P2g]mLcKrq(>Ts791mM)P_%5:aB*E@q=r&>8;8PYZH.d>R\2UE*:d5MD6hVDNWh0WM[MY+cpkg*("iaCt$U`up/GK>J\B<dV]P0$/i14G*'N<rKHcYFoP;RJ.!@[u=ORPDgE?SfMO_%_&J">Otm8-W$AT<e^Zk[\L[b1"(7=q.]&od09"*1LF*\2KAmJ-/f5fn/d"3mMu^<bb-qio6<%uK((Gen"-NF*qM;J[V+`'5hCk`iQrT?[[>n<)SE#(_Q[\<<kseqmQPr=G*HN!U$3gj"e'ET6pB08<Pi?9k;rUkY#uM-1*!G^Q5<(Rn`iNN,(q%[JEGe;k;_kYZM-OS]<pF0"?.W52JfrU!B@4782$SX9[A/>?8'h7R^st-BtF1\$?BZkiWDuIL/Z(*k2B9rN)<_32M<1WQ"NqUAd5)3Al)[95Uph3Oq5@4VPL(3DCQ\7()5tW&tOpAiZVNDSQui%8n9n,XedS2j^Q+><?8&>Fa)`_*QgE:[,TB6AY%]Ohk\2Uotmjc]plfma1+?:LJSB-0QaY1E.]6[PBE(kDl'r8+!=.(\3M/,C)O+=M>,,Tcf2cu7I,Lq70=,OrZKq&)>,SkR@f5.%`+LYm4B4TE*alZjpsaXV%7CF.Z-q7qOkNnSsmg:/A8VVFWn"OaPKAXe$&UDCgGj(i<Ap(qSn)4Hco>]PdCp;1CK/8_8nMSr%_6M'Z+X[R?n*0L</%#o,j[2(%29u%s*nXV9_fM'(Ir%r'uc0qS\Qs8E"f%6nRrGeEtVbR:_<)K+1pKUL[u#:U.K)?&STk)R#mB(iRCW7+!28IW]]f)NSO?UT^:[[oH`&Z:dcPG$<[caBE_=%0`\4VO#1\LY1rTbl1GF*Q_,42KVpugf12.nqWC"Q)>qM22+SU1,KS&]S<\ZJC)cm!,SKP%6F:q7Mbs$~>endstream
endobj
91 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3439
>>
stream
Gb"/)qfCN%&cUU9`K!%)3*1kZpnhsb*QM2#j_VnuPYPkk<^Ve,`.T<L55aWhol';N02W90P4k`eoHk(T!jE%`G!CAd,QSMWOQ?H;)W9ESHLD.O#pp$&MP.cG)Z#YmmmeWBWMkD\W1j@)8H_mK'qBL?kM(M>r*XA1&[cPZ^cE*liPG'j-'CS$&QG.!;2#&iqmlc[.6..j2,(a$j<phiF/:Y0&9OK,9NcXkB&FS`$CX_IrpN@/BLoEYK-6YPW#HlCWamT4/]VAME1EYkJj^jnh@LP+$B(NQ-Ms!f6)><+KLDI7r+@q2,`RCq3Sie3C85$6HA5\XCm9i@h<!Y+6ZNhnP<_p69\OPB)6L*=-X#h+;XQr0">b%k\Soq;0.OR?;)LYh7C/guW@[h^A9(m)=;FV^.-OIQ&:?hscgd<km.,N>MY0FojfUKSRgmk>-n,Oh`-S\BH+JnSa7D;#6^XHufsa'#IY'ZHq@D',obtXX]:.jZihAXYcS#+463"7D7'.0ilpVNJAeT;'qi4h\%D_Q[Lk7TSk_1(4g:dNGg'8%^l!4a?1Dl/X_lMJ&rqWKWC:6qh%@i,CZ\>uK*W"tRbX_u_Tdj?m0c9oWr-E<Bq_Ga#[RD_:S1RnI<-PT+QHQFEF:pe<gBfGJ,7.T5HpuZ[d"t:g\f,6;\XrKoIQJCS$)^h`3<P\o#3mi7_EpoJi&]^RTT:$>3#G)IdFB`,_:>bSRZ709^U1=@.i$lk-5X9I/J1m^3`c\:;<5GcIBq4D!Y8&5o&C#D/_<9AEgGRu0LM#Sd9`U>8UknH\/@uo(orr"?"6GXgo.j;c`nQpkWj2]mDC!p*Hrk/dI=dPkd1r8ZY6`Uk&cnMVSPE%:c&IU.bFHOs1TN\L86=L!WI.^FbD3\6c%F,>>!#+/rYuHZ.pMjr%+-<QA3lp@Z8(WL!@7<Bmlrc>BpI:q,.I+ndZr3V%.>'*D"XP*SALrOG]AcYa'E?X42]MMZq9cl]nddU<9GKYalPhm7q4i995;0(QH*-1O&U_#,Z+3TOPXUB40HppamT'JW#YfIDe\H7058>rjE*-@A]GK^4:qWh[AbRUdCnmBSFU;.B$_,jIj9MqG%$N=Z18I#05`nC&-pOE7qFq.,'>e]q.q7e?`j3GVn]ZVa'iZhcog,%ItkNO#+tKYP]Mj[DDO;a]jG@QkE/)=^WX/`5bBa&!fA:gduS?<iEUVG%[V0L.tUJ41\7!fp617:47N_b)lW\k=-\>Z_=\)8B8^<NVU"t."W^[;I.#='!pe?bd[W0ZI`.4CaTsFmngLmWfUa_cp[7]JhLgt&"HATpti@KbjPB>_k_Q6h$Mf5^f`^ijT6'5bR6QLmB6&!jD8n\o0$`H>mpcEVMEuQ>MQlXmS%JV\.HKM%,s:3W?KqDf%ZbGoF]0[?Y&C8=s#OqDf2hMe+(6HQQc-qqfLK_?+5I=e\_"hCmOR,G!srJ_^[;Nd^ei"iQmU*gJ%DL_EQ:hSHWgXqO:!gkXAIJQ5LYf1<$b<*qMTB(m8F,Q=ii=)VGa<q1D9rKNU[EjUWh=&LAC3+(rS87/!%?WNgfIT+Zrin^!;]<K6F'5Tl#P9,b)gk,"E>l0C/)XM5"Z;&eas:F/S.+=?[<o*B5h=$e\X;%0FAoki?BMOI]sd7L%mV'Yl:52s;aEI.ATpH@a=)S5J02dt_uru*)W^6M["M'FFi#UN>)C/7f+%7/;EF?\(aNT._]q;ZMHero_b=kU0(C=GVr`e'YK:dM53]lF/WI\J#9]FG&F2AK]iS**"\Haoa\Z!p=+95*?RW[\jS7d;QTai+8*U4b4Q8lG(fXBi940#3:s\<(t=Q=m+/QIIt`kfUJ\r4)a_aB9UJaHHBa=PjKhhhZ`B&Y4@RmsA,WVE^7:-&>HVX]FelI1]'e-$\n!T$:We=^*J]AX_Wq7q;=M^%RT+i:V$nIR5YIffqj:Z'",:e#XfnV=r-55J?6a_L;h,$-KVfSaC9M!!QH<7sPbsR+trQZLGp)V]g[DDS,)Ah+_tqen3r'WPNmf_nD;/1O%[.qj/!r>OK0d2b#obTc]PIk]^L8+8Y/em2`rg&^=KXYX2aVQ.K9oUTcsbkVH>>X<R6i%AicQQ"<i%i,;_CQa0.EA\$'he2*["]Z8[1@-QHFO,pDWQF*4"2P6P:@$Ds9></SmXU6W%e=VE<5Dl*_;\)o$@jHppo'u5HL0>1B/FgYi4KKNh-Lq6<JW3Chp]:lg6V;B$3`d#SX@:&7\I>($i%JJ:QQdpUa\N0K%WVo'N>-^Q#><d&l,XJL`59_U_2pJ)Co=i*:Dc%:NKo#+0I%bZVQdAC<A0=#.oQ<]b@!(9/d(R^@NC,+a->e4L4Q[C`f+T@Z0!WqQEhnsCqW^/[JrLE\J<QZiE\e6<rN+L;.u:)o;+Og`$8fHIFqZLl4f>t"Abr"#d!.!3]Z>hTUR50<,+UY(F2WjdBAMLHG9-NK'm]2d&Y:ppg?Z&)7E\]-:jS1TCka4;@Md1d1$,r/G(K;M$OZpeh*2?aJW*TgU#u_?k&=t&Q&i:#kNU(609aTrc%i@fbA615"U@MToeG-r5=ZI]u?c&3!q?0'3LW.j=fkaVBDPU=.%1V);7%krpNbGZ'a&n!e6c6PoUt_[6PR<C^niO=-hs:A@YZSI(RA^=WFqM<p\UB]hVB/%33Op^l^(OcQ!lIo6<<E,`?11[M;ieZ5luC!(^^q_9q,g6<$O&!mHacF)r%_Xm"e%POT1R9`+#F2%JjC=?p:r@a]3gPBV1N,0J]4iBN2#G>8ii[BlodD451BH=k!:[n#=*cUJ]bo0Am?0ZZqo1='h2l3lKlSWI+b/&XlhO-b2WrJB'W&B#c[qq_B-Y__+sS?GuKWMb[V;Zp<B.,i0?,uNcY/G9W&?7_U:@-@Fn8pX$QE#8WWIDZ+3@=&Ph/mDo2Jg$,dOA_$:r3#7qB[J.V1p+!0B>]bn4Upo/EIIjk]\,++/`)-rdXuK1.3$^\W44oJ?F*;[nSJ&X0c#*GhC@"+a@:Qa`nXp>i[DM)Ohh0G)'a1QD7BdIeWM]pW^p6Es4kMD*"(aX8^?e?9*RZJV(91YZ^]<:X,@]_P+Gd,A/#+kJ]6\tKB*fcfn`?NbI?LlgLI99aTBj\4cLc/YOV]70-O8E9.R=9$@6-g=HBBe>.(s()a2f]=.2RRR:UK*mT4*u6HU@M7enN:jBTDl%A'*oP7/<JCgg=28iBQ>5bY`CCC5`%5>)1F)+F'D<A&'(.&p9K[W^P5Id+JRD)1FJ@adaY^VA5J3]iKP:R_H)VKI2TB1MA$UR+35p8,%>'`t;IhU(MPSK4Em#$6H9"MP;.pW>,Ir6+0MCbWL=ol']EaU`WckOt5YPoONPr3Wn/r(p['i5Si_-p#$O;i%K')^amrb6W^&&>USeIr:f'!@:[k12eVp;O&OoQV@Q-<DWuV&f`pZ!SQ/o.p$]^?\W-#*W~>endstream
endobj
92 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2912
>>
stream
Gb"/)CN%re(B'h3EBh#jN@a@s%!I1YEDt11:?jf#]79oLZ"R>T=]4l_lWNS(h^_imjbIBp:5WKmUhqSenrPu.aE*D55f=b-rB$SQHl>9I1BA]9?ii_.'Gt(BB5mEh6Lp;.+c%mN5uTZ=O/ZNp/=QQkdh=AdUr]4-VB?IBK2Ws]87D)4M-t(mDCDab];R$6]5"Gm'E&JQ;;$kDOop5W@kY!^*e::`HZ<Wg!*h(eQf4Kr:<XbC(<P`pSkT`0CN@Ku?Wta2C)'Xl19<6$"D*EtV?!_gC5"Ja3VE[pr!A!_;TQ':Z[sLf>gl.5&G0jY!"Zd?mT$H3=keHXRK??*Eg7)Mj(<8li/8(1lgm=`_ejm(X6gBC<QjiplM[GrhT97!Z2[5*88'+H\KrTkkTaa*Z#\$K?KDrK&(fDA";e3<0S4?QT'2oE\#dsYB*(Xm)d*t7DV!WSb!7pNn3>7r(LPJb_\UXS*uN(2C13+8#L@ZjkXTSSRso;:`KYXpmt]3<nb*X6O$)Y(CTsE@-JUI:H@@i_>j!bT_m8m"$P32jBb#lB(b;AL(!ZGN_[jMP0:Ka5?^T3l#eL%VIZ!S;plh3j(q+6q-NR>0h:*]`3q#W!fH!D&m!Eo_T.#/5^5\l&Y?f/4c"0T(>^HAR!QF<b;(lhF8QC!?6GEb#8>D;I%pRK#O+)@<kR>$]*QIcDWIa_d%WUI^N-\1i."ldEj,dG![@-7;7ka(7?m^6Rejgr&]^7"rbrcnnFS=,'qY"SV9AjX4)NSSH0b/s\Soa<T26\u_YH93Ba_^#72f>(lga[J=b\6>j%<'/L61_sCl<a!4Ik31Q0loo6<oV+Am_JfBs8I9f(m;&#"?(c(Y>,38^1GuS#]j,1QS.(RSMF2:"6IKO0!5Gtnoe34VFI#0<^Uo((66$G_TO]f3G(.%QUM0Yb=Vo@M?'N7PlP,*!DFC^!PBl`Ql&j*GZ=l#*NTD5WX.N_-%LJG_d"9p*[%JqS3n_3ji**!=k&`n+H;^(7JuJkHr3j/1K<F%6R5qb>DpG"J1Qrh2Ki*e)pTfTi[i.a"Cljm?#%reA3J\3s2.icq%5)[.i0QZ>V"WO0\bE^^=l8SH_7DZE8-/jh7l>Gp5`Wt)f@&q3]Q`HV/tG/XP!&+G]>:=K"uNKbNn\WNh>7]igj`d<Z!os0pC^TU,^*1IC_sn6Fa9ea1D0O0/SX9]#3*1J+u=.I90M"MFJVo7`m0#CFkm=^eAZG\]+BlR'G4#?hd$HY#\^B6pm,;)$Ds@-:#pXa]'bX_n-0GZ[+lJOj!ojRQM]>-UCqZ;LCEk1Sfa15S"V2jmXSKJU;i>2s5[up)]uf5Po)6EVNto>b(D@_<n2U]BFHV3^&4fROgnUZ.;1KQn[[<r9-7'0Y^1UaaodO,@HfUdO+q5T>nLo,B_n\+';Z<P4:FO_(G(]<pYUg4GJJ>JM;r?#++T\4(-G&r>XW&jFr:&-N#`%rg5]#FWJ[B-E^A;(*6obN%Jr^ds9l,<k*A^%tK][ILDf>('mTQg?a1-#FPQ_d=-H$#TnSX+t!6Dq:'?@;//V*[(4$D(>l4iLOP&uOD<.)lcbnZV//![POL0:#0m@9"b69B'#e-JBXod;]*]^0_;4E)nLPqsLTC'5()]%j];J'<C+S]m5stWX@g;"s6<UP4<HpG(bR+Y67R/#u2$+It(0"-hCQ/<_1!9V'A?0agkJ+^@Wf6s\[CmqhI8BdLEZ-8>FAUi7O3Ep`n`([Wp$lfLQDBQ`or+BVPIL9'IYEGn,\+^3O3NmXnS>=#_O%=+]a5dq*+DV:pjm](^=W1d_^W+k)rMCbY9K0TK\sU$M>:E$+^1jGGVgnHZJ9>koJ>g"Z>[A:9-1690=%G]P,q6_l:"aLZG6M+7G1Wn<aoRmjneWS6(qR\W`30YkHhq8iBL!M0c>iRM'@CZ2GqDRH^lMD2_A0#&O5R#=)`%_A8Gg7LNo9[KkZ>'#;.ra=lQ\CfhXRr1I$ht[rK^Xj5uf=G!;XoS5$]9<gfX,Wip0'Eh#<W;J*7PjKBf2fhS42j/sCfAN^+g.L2N7i(_1pUAd,(Ct%$W(ps5B,+.U7m(5d?@+$oZKOqt<O7<1nm43rjj7_U)>.lK/&dg7$lP"97J1(u>_%%`f;&7eH(IZ]>,>!T\:9U_Q&J53Ag4uqODeYD@%2lIEm:lnQMj-m/qBJNuU?cdc4JlK@as0GTIM@TaG3D#2q`-c540s'+W--Q0i+hP4&0rD^^&)gToJpnt//OSenf3eXN2[Uj^`256,q+Lf)[497:Rr[H_8L=B4OT(I)Kefm.:kIZ7!,t+n?,i776oPjJr/U!M_?TCo+sOl8_&R$Uo+F=?17&'F=o2]78`*^%81n%l.F$qiHqLii\QrIXp^XG8It3dVl:YSMKEe#N*.,*d94(%#nuk+GJOffmJj!ZkC5E3'[pJ@U.WJSH!u\FaJ0iQ8&Qr;H`GI;MWl,#BaC<o!h]GO!n(ESR^/M*#(n=l:2,sD@dC%GX+4p8-aSKiAHCY3NsK\D;"+=bbKphu'(iK#RU)YJZMU%'^nWc9_RHV;K^NQW6>>*GYle%b#RbG9i\L!ie)u4h^H\T[%@\=l#</O4h<d?P6.hOt$Y`[REaq<BP0riXrOD.Zm`*sqAGbC44gIWGhdC>U:V-h;=XsdBKSHu^25mHo>WR.II]MLT%eJ:R#eYHm>WEi#-7&%b6->t]'pc[nFom=S&C(D[rKr?(aq4D@S@QssQ%#Ni!+34sIY"Q9:f?pL(;MrVnk`;jk+RS>1u-RLK`V`5!\8/73,+NB)0c,*_NcX)LFt4-<pYe(1%B)8bA^,@;lkV`@l>lC'2NVoo=_Nd1gS@ScWr6+Fc%e"R\H,=?-j<kh)*d^]s9/ocC'9Mq\9i4eF]]FC`9h4#q@)f7S_-W2DVYj'0,e0+ruV%~>endstream
endobj
93 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2896
>>
stream
Gb!#^gN)%.&q0LUoV43PP6?-\b`e73'@4[T7oJKk<@N`W,UO5)+\5u6laK0B'IiW'HagF_Nm+0L(SK1=1ggA'IRs19]*3Go!;2c\Ts.W"0ba^*T4,;jqr`q!P(or<Ni<n(gU<j@'*I.0@=43%\d3l+(+>G$X4K1T?=flM`+q=[<.U*X(IZSR*[^>`nH0QA82GbSk?uGQci>f:U6CH\7<>DFlCd>S9=#X8Cm.U&Jr7l@q3k`bNt[3[C;D4D+m-$*"a.s`+et5J)'r3p_!uX=(3(gB@<;sI-e8T]*PL'7>AH"MUXsI2p$1ZGrhB@oH,t#^Jt8_LI??cU:;=MMas&(qfgB?APStlH_>-gB\#jpBFfJQ4Fctb!=ci2!c.0;bDWln0C'+m-WkitlgdYTgE7hF+1E`P\-B+s]-$+S,cr8CQ\.kD\:3ak?<_I*mZc>Nij4btBT!npm-VeH&R-^k@meNP9XSq3=<F'<7:J)>)<>Kn#jEiKmanK'\rSVSA&6S2R3S^53r^5r(\L?V;ra!PY39Jj2\=V^3]RL+mbqBg?9n:A"<t0BuGj*/n]0qqSW'Vr2UcDn?6F`V17VU>V;8/ZS(V+N"[9)TJdQuT&M5ibD=FGRE<.i8'_'TeO\k18?:t]_6<2"KR@SXq_'!D3&WBUftF2.*`pIMWW@@<Q%:fdMbYZ?VerS<J](M,e]A=*5"QHRE#B'W2ce3P1i=mnQ1?!cFEp4kh<opfm(O@<"H9L+hYGRTrpEqLQ4_;h-UZ@mRO&UssM`&r9TEQl9fF9G0pZ(BaVfOl;`pBO9Z(e]!=P2d!3G;jom+Q%?f#+sqmI32Eoak0Fe+^5-QMJMC(CJ<6]4!(^2=2p5C4r7$2`s[^P:r(Xhj1$1b(Z:'dOEr==AL\CNGt@"`bJe;enk]2ZEMb_F2G=1mhm5Pm'GDN\Y-pK4$2GFR4<)_K"'Y*,[>J?\Ud^F,HIX0d/;K>6<JU(K(d]9F)8W4[!4c9E!2#,L]B4ZmE7q0-0b-+'HW::>W>Ilm<>iTMr;]8)S?9E1<'D#$rcOGO,u;"KV:[]J[W\tWYnWW7*,c3i.a0*2amVBZGNsP899^S!q^;&HmZk<Clu?`nM8k/'L<5nR^]NE:EXRO>qn(p65NMtEqiZFF4FZd*/ekQN<gnc"+$_FY^&Xbl(/JZ#09neF`R*?e_K8='5.704'ps&$NW%qK\",7XE@T6*FYfK;bMaK=":fAEG=`Q1U$'cM]mAol4VI#IOa;?aB*_eAFTr4)OmW3u2d5`&I8SdTG9"%K)PXb],FJZ]2l[_3j2RQR8<Ng+G_QY@)[).6"\[-M*im.bGN5GUUh?`r)oTA/X.A?`9G6Sq\dWs&q/dDT*T-b=B@m*R)l0ZMHGNG7,D#h#3?*O&`G<$D8T\3I$+1LBIT5L/M7@dRWjPD6&QE9mgmP%%M%cpco9La4$U$Rh@jM*CjFkh2q*qq/@a]V5WpYDU5J_kDGp0L9ZR5C..1(YQOlGEl)3QroP5O`l6<@i2FSkko>,/LuJFeE@Wk\$.V"$aXL/f,pomQbM$n@L3?=juhM@Jk[CimSj@F,c-XnI[!Q-k^>qT6Xu,nmd>=*HU<"7rsM#iBMB'p9)K+XBGN'r2pul.7XpX\MPID'6_J"^;g]71nO/Q'0i+8m5`Vp43l]2+h[*(bqb/Q'+lPPdJGcWE#YDF`uTc<HG?J\Z0)-$5O;gQ(f.^Tieijlh^'ACWRG%p57rsdSCXoVq\6_)/DZ'"I[oPPHsiUA%I2;8o=>__f*GaVs?4_i*0Xj^GIL934`P+H]Jh;#`N>^L&t_KP4l2X+!Af2(4$Pe>JHqF(5u2$[#3Z\b%W^S$.g#a8P``5s0'Yn*-0P*,B0::X:I9])QicZ?a2FI<]urm)P`?3DV5\NRsg$AL[-+]dGlfTc#DMRe>:>48CrKB*cE7c0Q2Zq1C9DaYbW/?*K-pu:d9(9V(q<J^A"T)QH4H=HG42=d#9N3rrnLUB]-..!VbbpS8iMDZ2%foj0A;s(q+0L/XAC*KM/jf%'HN`CF;"erEfS?mQrFqlDC>DLmP6"U`0RO\\Ml@^#_iP]5kSQM3I]&IOc9p\5=.k(LT?gWTMYC`,Zr1n@CueJ,)@f/rm.Tf^L^Z0&E3W,4QWS[Q=DBWRafZFJ>*'A&6_q@)9IdOTJZ7cJQH/+0mf]3nFqiBY7gB]8pf^c(c6H=+]Lp?FLE-m(&nj%(pFk<#iHMT`>Nlace>R+:X*jD`[m3ZgkJ)9k=+6]]kWL-]I%:$iGMGM[ThD@"F3E/D2#gk#pC=N2M3MI#G,R)kg@5?+%6sqEbqFCiRWr*!X.iT%2,^#G]uA>GJLY#lKbd2OPf9]CR=uh!bmKQ\PE\[k4^f?T/7RD@e)l(EIFIFefuB./l-03k?tZkR+#IFtf_FPQG>,_nu*u:#rj_E2+Gjh"_':\;N"WP)6#;V9Pn(`>[<b_"YdUiNjX*"GGPuG-PkK+T$4ihOW4&qi4[5q'>hZ;NXB%X;2+]2j(n/>s?F6Q1k'(Y`Bh)/*"C]:%k)J,4b,5J3hA+D88)=_Xi+Pgt0F;3;Sa8PmUdPr<nr56\eHq`>u%AhoNto,WC!?K9[O:Aifh+;6&!"fhQLBY*B&ue(%K,`e3Vm?N.XW!,mr4fqks;VCal.62^;`4E<^po&0,[!L>UL'';cORmCXE^%ut8<OUc@/`F"c.LJ<52(uH;Y"]<b-XH[^IkH/[Sm@UF>Yb`l0TUpeGrSp0&'4t/`R3-7LE/`Wjf9Au)T?_j[.!VNm-gDSe+mDRo/g*NB2"m<hk0B2CCG4T3$dO$rRK@Wqhf08F]\F(2&c?RkTn/!(uU&ofsOgt42j)`Yq7s74tt7*%0WZ#C0'+IOZ_:m_uXU&-6Z.Hq=t.moXa/kSG`\-^@(^~>endstream
endobj
94 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2141
>>
stream
Gau0DD0+E#&H;*)YsZtp:;]YI8-73/A8m-1B:OVTRH[CZ4[L,2>=aWH34MQZHh[.666HB%(5tQ5]m[M@G5"Bj`A`eVgN.n'!@X9K."f>=W(&T\gd#Lko(@[5S=333VQO[\BJbNK2`4iqA6_Z,Z7(FC*#OltPtMm":L^=2i=r<!8YG6",0-1iYj2&CgkqU,5!1oZ_&Wqj8r]J5]h>EBLq[(D&u8^2b3thPO63=r4t:Y74Nr0b6**_i7$3Y3.`b"`/r:ZF;aS(,8hs/-hIq,:_=GSWo!&jK3s%.C64VD%K+qQmr]:6`1_V!7`f@S#*A;.Ji$Vb+q.hqMP;l)F+*Xj63ltX\$p_Lt6pNeEaIa,[_[TRX;&"hPU(huC"0)?Y'fa><<G($,UD^.>W?:u6W$0WL<TH%dn@nqe,:$01L_hj3N4AV0nX4o9oj)21?PbhP"@%S-Vu@f>^!<4>eute<?+>gW:D.'<4[\Pu*csW\b=bUk61K*)jLWLb.5\,\I*.d-psZ6;e=-3bGN0DqIMNL"1J^ue)RL[ZA1*#W?n6t!'1nrFM=ef1\/0#XN\>>9GY</=Z@e,7iRr,67;%8$^$=`(S&WS&<9][$0AcGdj9e5'q03i\69qTNDOM1kH$T=23Sp,V8MCPG>Pt,,p)mdu<QO:\1ILr-TM+@B!;O=G;!'S!KGYq0X".>e!a6aZA[!p..T*I_Q,?3QM4QIBh1mUE/XS(o(V]OEj(*<-(a:%S3lS7Tl!*@!nBJd!?!t:]^=.\^(5BmD*8:u7>lPChk6(gHL1b%H)_s6k'o/R?]KeC`L.:.^mDO"T-sF-t@sjgRbla+E84CqB@cTSYZAI&#"tY/@-#kLbo#]H(B3X0#%uQK8W+"9#Es2fJN8`D\aX#6Qh=AS2'&#$M$k)kTFM[\Lq[n;NS#;`&0im"P(OVP!qdPY6$dZ.K3&:k&F99[kO_*FF2%&&69C!-"Q%UWO0q\?PdQSjeq^p9cNKn(1[O/$>-,#-g"#L68gAT=m'*D%MenOk>^%4aD&_<[q+])r+o$Oihj.P5GWcro\M&c*nqThOgnORr@+[g<uDlU=-GQ1jXInicq&[@^J[qZT(LWU!=gZH"G.H.E-OYjSm2/0iV0HB5tDs[m,htS6*U>"N[i%;0b!24p![I5Pg_Y6e5$c[/^]n3gFXibA>7H,KiH;ti&*)jc9FqUMDFqc*8h,)XoS%eR!54o+AJur-s`9d>&j8-^0T)/mh6@TJUJR&s_Ym>_Qn<H:rf:mbShSp0Yn,oO2&"qL)e%^[gX21J+j2Ok7C(8M3!aP=_M<4gKIIX\R)*=>ko2\GCf>@?=T4<SSltZi0;^\#+NDbRJ%?6`(_pKTRS(?[]k$JKO/B*sbl'<EG-QMeY"B;psR^urfZ4Pu^$177&p[?;u!L?Qq<)HpNWhCT)@:C4jjZ3YtG*'2gFp)\D2R'YRba\n>Cj=M\Q>AeDTbV!aNtDeAbHn'n/gjX8O-N%DSNNGXc>g20BWLje3N^BRQTG0[hciguZQ?oj_QiI2eiZ+tidVZj_a4pn0AGVKK.:iFE*TI^_m<G&q%6_3"BF\Np>u>_*qG59a")'f7MF,&</8%^cNTZ69ifm35jG5?2OZlL3\rWCZd6O#3l>5X$)>^p)TEgoTp(fk;SH'Fm-Zr;81[Mgichs>H2#G"]2,]MNuD-KM(.2*c@Ci;PA!RmPR9*S+LQ&h+\DjXlAQR[nLVe?W!L3:@[RnkNj.XHl=aM[#7#<OOU7'T%7sPpP$@;I/lT6]`Cs[*N^%<H=n\s<>%2Y*0O7aOT$0ap)tYc:Di1<R-#5f/<@Ms"^aq@S%[bU5#3Wu<.38N/VpX0>QK&]O9KurG4'#[3&Pb@E>_,D-nH&N3J:&q%J%[5!Eo5p?mY!E"6dmIu^O,EEYl9*pWl9>p>.emL*u!0%I<FSY]&q1UlkY5M-:bkPN.=ti!rca6X;a/$?6o`obTo_We*8.-oXV%Ti<VdhVTDFLA6i7C9,WaRmX28*RH&:kj+el]RmI,bE`+?nOB;"sGNY"ic=[NieY4s`&&.3jDOp.TM88^K,$-ZpGqO%qZL0j@XCoW66dDg@+UsFZL&o9^Fhru;@cZ!qQ]%0TRM\NJ"GXfTV9Lu?<N9MZT^%7Y3;FB\.Z_U~>endstream
endobj
xref
0 95
0000000000 65535 f
0000000061 00000 n
0000000146 00000 n
0000000253 00000 n
0000000365 00000 n
0000000513 00000 n
0000000661 00000 n
0000000808 00000 n
0000000956 00000 n
0000001104 00000 n
0000001252 00000 n
0000001400 00000 n
0000001549 00000 n
0000001696 00000 n
0000001845 00000 n
0000001994 00000 n
0000002141 00000 n
0000002290 00000 n
0000002438 00000 n
0000002586 00000 n
0000002734 00000 n
0000002883 00000 n
0000003031 00000 n
0000003180 00000 n
0000003327 00000 n
0000003476 00000 n
0000003625 00000 n
0000003773 00000 n
0000003922 00000 n
0000004070 00000 n
0000004219 00000 n
0000004367 00000 n
0000004515 00000 n
0000004663 00000 n
0000004811 00000 n
0000004960 00000 n
0000005076 00000 n
0000005182 00000 n
0000005260 00000 n
0000005409 00000 n
0000005493 00000 n
0000005699 00000 n
0000006137 00000 n
0000006343 00000 n
0000006549 00000 n
0000006774 00000 n
0000006980 00000 n
0000007186 00000 n
0000007392 00000 n
0000007598 00000 n
0000007804 00000 n
0000007891 00000 n
0000008206 00000 n
0000008281 00000 n
0000008373 00000 n
0000008476 00000 n
0000008625 00000 n
0000008715 00000 n
0000008807 00000 n
0000008917 00000 n
0000009039 00000 n
0000009184 00000 n
0000009286 00000 n
0000009402 00000 n
0000009515 00000 n
0000009630 00000 n
0000009718 00000 n
0000009848 00000 n
0000009960 00000 n
0000010112 00000 n
0000010203 00000 n
0000010315 00000 n
0000010425 00000 n
0000010520 00000 n
0000010627 00000 n
0000010733 00000 n
0000010842 00000 n
0000010949 00000 n
0000011072 00000 n
0000011176 00000 n
0000011294 00000 n
0000011405 00000 n
0000011525 00000 n
0000011653 00000 n
0000011747 00000 n
0000011872 00000 n
0000012819 00000 n
0000013945 00000 n
0000014176 00000 n
0000014407 00000 n
0000018061 00000 n
0000021370 00000 n
0000024901 00000 n
0000027905 00000 n
0000030893 00000 n
trailer
<<
/ID
[<dfb56bfa7fa29c8df90718f7ff48e788><dfb56bfa7fa29c8df90718f7ff48e788>]
% ReportLab generated PDF document -- digest (opensource)
/Info 51 0 R
/Root 50 0 R
/Size 95
>>
startxref
33126
%%EOF

753
specs/20100612_1_scaling.md Normal file
View File

@@ -0,0 +1,753 @@
# Platform scaling and multi-environment architecture — specification
<!-- doc-meta:start -->
| Field | Value |
|---|---|
| Author | Anton Afanasyeu |
| Revision | R1 |
| Creation date | 2026-06-12 |
| Last modification date | 2026-06-12 |
| Co-authored | |
| Severity | medium |
| State | scheduled |
| Document type | spec |
<!-- doc-meta:end -->
\newpage
\newpage
---
**Document type:** SPEC
**Source draft:** [docs/drafts/20100612_1_scaling.txt](../drafts/20100612_1_scaling.txt)
**Design review:** [docs/DRs/20100612_1_scaling.md](../DRs/20100612_1_scaling.md)
**PDF:** [20100612_1_scaling.pdf](20100612_1_scaling.pdf) · Regenerate: `bash scripts/build-all-docs-pdf.sh` (Mermaid → PNG diagrams; requires `npx`)
**Date:** 2026-06-12
**Status:** Planning / brainstorming (pre-implementation)
**Severity:** Strategic (post-alpha evolution)
**Scope:** FE/BE topology, service decomposition, dev/staging/prod clusters, cloud migration path
**Documentation index:** [README.md](../README.md)
---
---
---
---
**Documentation index:** [README.md](../README.md)
---
---
---
---
## Table of contents
<!-- toc -->
- [1. Purpose](#1-purpose)
- [2. Vocabulary](#2-vocabulary)
- [3. Current state (baseline)](#3-current-state-baseline)
- [4. Goals and non-goals](#4-goals-and-non-goals)
- [Goals](#goals)
- [Non-goals (this revision)](#non-goals-this-revision)
- [5. Requirements](#5-requirements)
- [5.1 Must (platform)](#51-must-platform)
- [5.2 Should](#52-should)
- [5.3 Future capability slots (normative intent)](#53-future-capability-slots-normative-intent)
- [6. Target architecture](#6-target-architecture)
- [6.1 Environment tiers](#61-environment-tiers)
- [6.2 Service domains](#62-service-domains)
- [6.3 Reference cluster layout (prod)](#63-reference-cluster-layout-prod)
- [6.4 Traffic and control planes](#64-traffic-and-control-planes)
- [7. Service inventory and scaling profile](#7-service-inventory-and-scaling-profile)
- [8. Deployment and promotion pipeline](#8-deployment-and-promotion-pipeline)
- [9. Zero-downtime and load balancing](#9-zero-downtime-and-load-balancing)
- [10. Data and storage](#10-data-and-storage)
- [11. Simulation lab (Alpine HVMs)](#11-simulation-lab-alpine-hvms)
- [12. Capacity planning framework](#12-capacity-planning-framework)
- [12.1 RSSH / remote access connections](#121-rssh-remote-access-connections)
- [12.2 Cast and media paths (future)](#122-cast-and-media-paths-future)
- [12.3 Network bandwidth and egress](#123-network-bandwidth-and-egress)
- [13. Cloud migration (GCP / Azure / other)](#13-cloud-migration-gcp-azure-other)
- [14. Global availability and GEO policy](#14-global-availability-and-geo-policy)
- [14.1 Reach model](#141-reach-model)
- [14.2 Segment isolation](#142-segment-isolation)
- [14.3 Enforcement layers](#143-enforcement-layers)
- [15. RBAC and operator model evolution](#15-rbac-and-operator-model-evolution)
- [16. Commercial and paid services (platform hooks)](#16-commercial-and-paid-services-platform-hooks)
- [17. Observability and SLO](#17-observability-and-slo)
- [18. Safety constraints (current topology)](#18-safety-constraints-current-topology)
- [19. Phased delivery map](#19-phased-delivery-map)
- [20. Out of scope (this SPEC revision)](#20-out-of-scope-this-spec-revision)
- [21. Related docs](#21-related-docs)
- [Changelog](#changelog)
<!-- /toc -->
**Documentation index:** [README.md](README.md)
---
## 1. Purpose
Define how Android Cast evolves from a **single Alpine BE HVM** hosting all backend services into a **multi-VM, multi-environment platform** that supports:
- Stable **dev → staging → prod** promotion
- **Horizontal scaling** and **zero-downtime** deploys where services need it
- Optional **cloud** footprint (GCP, Azure, or hybrid) without breaking the existing Gentoo FE → Alpine BE production path
- **Network bandwidth** budgets and egress planning alongside CPU/RAM/connection limits
- **Global availability** with **GEO segment isolation** (worldwide reach by default; denylisted regions per content/policy — e.g. no publish in Zambia)
- Room for **future RT/AV**, **VPN**, **payments**, and **third-party streaming** integrations described in the draft
This SPEC is **normative for planning**; individual services retain their own SPEC/DR pairs (URL shortener, remote access, SFU, etc.).
---
## 2. Vocabulary
| Term | Meaning |
|------|---------|
| **FE** | Forward-End — Gentoo bare-metal / XEN host; TLS, public nginx, UDP DNAT |
| **BE** | Back-End — Alpine HVM(s); app services today on `artc0.intra.raptor.org` |
| **Cluster** | Named set of VMs + shared config for one **environment tier** (dev, staging, prod) |
| **Control plane** | HTTPS APIs, consoles, heartbeats, RBAC, orchestration metadata |
| **Media plane** | UDP/TCP cast, SFU/MCU relay, ffmpeg transcode pipelines (future) |
| **Sysop** | System operator (PO today) — owns infra simulation and promotion decisions |
| **PROD** | Production cluster serving end users |
| **RSSH** | Reverse-SSH remote access track ([REMOTE_ACCESS_IMPL](../REMOTE_ACCESS_IMPL.md)) |
| **GEO segment** | Country/region group (ISO 3166-1 alpha-2) used in allow/deny policy — e.g. `ZM` (Zambia) |
| **geo_policy** | Per-resource or catalog rule: where content/API may be **served**, **published**, or **streamed** |
| **Egress budget** | Planned GB/month or Mbps peak per service/link; triggers scale or CDN move |
---
## 3. Current state (baseline)
Today one **BE Alpine VM** co-locates (see [20260608_BE_SERVICES_and_infra.md](../20260608_BE_SERVICES_and_infra.md)):
| Domain | Services | Stack |
|--------|----------|-------|
| **Web / console** | Hub, crashes, tickets, graphs, builder UI | nginx + PHP-FPM |
| **APIs** | Upload, heartbeat, remote access, graphs ingest, short links admin | PHP |
| **Git** | Gitea mirrors (`AndroidCast/*`) | Gitea + MariaDB/SQLite |
| **Database** | `androidcast_crashes`, `url_shortener`, Gitea DB | MariaDB |
| **OTA** | Channel JSON + artifacts | nginx static / PHP |
| **VPN** | WireGuard lab (`wg0`), RSSH bastion | kernel + sshd |
| **URL shortener** | `s.f0xx.org` | nginx + PHP (submodule) |
| **Build** | Docker builder orchestration | Docker on BE |
```text
Internet → router (134.17.26.161) → FE (10.7.0.10) → BE (10.7.16.128) :80
```
**Constraint:** This topology is **production truth** until a phased migration explicitly replaces it ([INFRA.md](../INFRA.md) §1, [bottomline_reminder.txt](../../bottomline_reminder.txt)).
---
## 4. Goals and non-goals
### Goals
| ID | Goal |
|----|------|
| G1 | Split services by **scaling profile** (CPU/RAM vs storage vs RT media) |
| G2 | Three **environment tiers**: dev (unstable), staging (beta), prod (stable) |
| G3 | **Seamless deploy/update** with rollback per service |
| G4 | **Cost-aware** sizing — no oversized DB VMs; burst CPU for build/ffmpeg |
| G5 | **Simulation lab** on Alpine HVMs before cloud spend |
| G6 | Provider **L4/L7 load balancers** ready for stateless tiers |
| G7 | Preserve **dual-remote** git flow and existing public URLs during transition |
| G8 | **Bandwidth visibility** — per-link and per-service throughput metrics before saturation |
| G9 | **Global reach** for public services; **segment isolation** when PO denies regions for specific content |
### Non-goals (this revision)
| ID | Non-goal |
|----|----------|
| NG1 | Alpha release blocker — scaling work is **post-alpha** unless PO reprioritizes |
| NG2 | Immediate full cloud cutover |
| NG3 | Replacing Gentoo FE in phase 1 |
| NG4 | Implementing payments, SFU, or DNS resale in this SPEC — only **platform hooks** |
| NG5 | Full **multi-region active-active** prod on day one — phased via CDN → hybrid → regional PoPs |
| NG6 | Legal counsel for country block lists — platform provides **policy hooks**; PO owns compliance text |
---
## 5. Requirements
### 5.1 Must (platform)
| ID | Requirement |
|----|-------------|
| P1 | Every deployable service has an **owner**, **health endpoint**, and **config outside git** documented |
| P2 | **Prod** changes require promotion from staging (except hotfix path per [GIT_FLOW.md](../GIT_FLOW.md)) |
| P3 | **Stateless** HTTP tiers scale behind a load balancer; **stateful** tiers (DB, object store) have backup/restore runbooks |
| P4 | **FE** remains TLS termination and edge routing; BE/cluster addresses are **internal** until cloud edge replaces FE |
| P5 | **WireGuard UDP** and **RSSH TCP** paths remain explicit in firewall/DNAT docs when split across VMs |
| P6 | Simulation cluster MUST be reproducible from repo scripts (Alpine + package sets) — see [§11](#11-simulation-lab-alpine-hvms) |
| P7 | No single VM hosts **both** primary MariaDB and **unbounded** ffmpeg transcode workers |
| P8 | Every **bandwidth-heavy** service (OTA, object store, SFU, ffmpeg, CDN origin) has documented **Mbps / GB-month budgets** and alert thresholds |
| P9 | **geo_policy** model: default **global serve**; explicit **deny segments** (countries/regions) per content class or tenant — enforceable at edge and BE |
### 5.2 Should
| ID | Requirement |
|----|-------------|
| S1 | Use **managed** MariaDB / object storage in cloud phase when cost ≤ ops time |
| S2 | **Blue/green** or rolling deploy for PHP-FPM pools and Gitea |
| S3 | Central **secrets** store (SOPS, Vault, or cloud SM) — no secrets in git |
| S4 | **Per-environment** DNS: `dev.apps…`, `staging.apps…`, prod unchanged |
| S5 | Connection and CPU **dashboards** before scaling RSSH and SFU |
| S6 | **IaC** fragments in repo (`deploy/`, nginx snippets) for each new VM role |
| S7 | **CDN or edge cache** for OTA/APK and static consoles before scaling origin VM count for bandwidth |
| S8 | **GeoDNS or latency routing** when a second region/PoP is added; single canonical URL preserved |
### 5.3 Future capability slots (normative intent)
Draft “future key roles” map to **service domains** (implementation deferred):
| Slot | Description | Depends on |
|------|-------------|------------|
| **F1 Multipoint cast** | MCU/SFU relay outside LAN; TURN/STUN | SFU SPEC, signaling |
| **F2 Stream transcode** | ffmpeg any→any; RT and non-RT | Media worker pool, object store |
| **F3 Cast storage** | Recordings on BE; paywalled retrieval | F2, billing |
| **F4 Commercial VPN** | Anonymous RT VPN; capacity planning | WG pool, payments |
| **F5 Payments** | Low fixed cost at scale; usage-based optional | F3, F4, add-ons |
| **F6 Mobile add-ons** | Masks, background replace | On-device or cloud GPU |
| **F7 In-app transfers** | P2P or ledger between users | F5, compliance |
| **F8 DNS / subdomains** | User subdomains + site builder | FE DNS API, tenant isolation |
| **F9 3rd-party streaming** | Ingest HLS/m3u8 or RTMP; re-emit as cast | F2, cast handlers |
| **F10 GEO enforcement** | Region deny/allow on publish, stream, download | F3, F5, edge CDN/WAF |
---
## 6. Target architecture
### 6.1 Environment tiers
```mermaid
flowchart TB
subgraph Dev["DEV cluster — unstable features"]
D_FE["Edge / FE stub or shared"]
D_SVC["N × Alpine service VMs"]
D_DB["MariaDB (disposable)"]
end
subgraph Staging["STAGING cluster — beta"]
ST_FE["FE mirror or path prefix"]
ST_SVC["Prod-shaped VM count"]
ST_DB["MariaDB + anonymized seed"]
end
subgraph Prod["PROD cluster — current + expanded"]
P_FE["Gentoo FE apps.f0xx.org"]
P_SVC["BE VMs + future workers"]
P_DB["MariaDB primary + replicas"]
end
Dev -->|"promote on PO sign-off"| Staging
Staging -->|"promote on stability gate"| Prod
```
| Tier | Audience | Data | Deploy freedom |
|------|----------|------|----------------|
| **dev** | Developers, agents | Synthetic / disposable | Daily; may break |
| **staging** | PO, QA, friendly beta | Anonymized prod subset | Weekly; feature flags |
| **prod** | End users | Real | Hotfix + scheduled; zero-downtime target |
### 6.2 Service domains
Logical grouping for VM assignment (see DR §Architecture options):
```mermaid
flowchart LR
subgraph Edge["Edge tier"]
FE["FE nginx TLS"]
LB["L4/L7 LB optional"]
end
subgraph App["Application tier"]
WEB["Web + consoles + OTA metadata"]
API["Control APIs PHP"]
GIT["Gitea"]
SHORT["URL shortener"]
BUILD["Build / Docker"]
end
subgraph Data["Data tier"]
SQL["MariaDB"]
OBJ["Object store / recordings"]
end
subgraph Realtime["Realtime tier"]
WG["WireGuard pool"]
RSSH["RSSH bastion"]
SFU["SFU / MCU future"]
FF["ffmpeg workers future"]
end
FE --> LB --> WEB
LB --> API
WEB --> SQL
API --> SQL
SHORT --> SQL
GIT --> SQL
BUILD --> OBJ
SFU --> FF
SFU --> OBJ
WG --> RSSH
```
### 6.3 Reference cluster layout (prod)
**Phase A (minimal split)** — cost-conscious; matches draft “tighter” intent:
| VM role | vCPU | RAM | Disk | Services |
|---------|------|-----|------|----------|
| **be-web-1** | 2 | 4 GiB | 40 GiB | nginx, PHP-FPM (hub, crashes, graphs, builder UI, OTA JSON) |
| **be-data-1** | 2 | 8 GiB | 200 GiB+ | MariaDB primary; nightly backup to OBJ |
| **be-ops-1** | 2 | 4 GiB | 80 GiB | Gitea, url-shortener, small object cache |
| **be-build-1** | 4 | 16 GiB | 100 GiB | Docker builder; ephemeral |
| **be-vpn-1** | 2 | 2 GiB | 20 GiB | WireGuard + RSSH bastion |
| **fe-1** | (existing) | — | — | Gentoo FE — unchanged |
**Phase B (scale-out)** — when metrics justify:
| Add | When |
|-----|------|
| **be-web-2+** | PHP-FPM CPU > 70% sustained or p95 latency SLO miss |
| **be-vpn-2+** | RSSH session count > [§12.1](#121-rssh-remote-access-connections) per node |
| **be-media-N** | SFU/ffmpeg SPEC approved |
| **CDN edge** | OTA/release origin egress > [§12.3](#123-network-bandwidth-and-egress) budget |
| **Regional PoP** | p95 RTT &gt; 300 ms for target markets + media revenue (F1/F3) |
| **Managed SQL** | DBA ops > 4 h/month or replica lag requirements |
### 6.4 Traffic and control planes
| Plane | Protocols | Termination | Notes |
|-------|-----------|-------------|-------|
| **Control** | HTTPS :443 | FE → BE :80 | Heartbeats, upload, consoles |
| **Git** | HTTPS / SSH | FE path or dedicated | Gitea behind same FE prefix |
| **OTA** | HTTPS static | FE → BE or CDN | Large artifacts → object store |
| **VPN lab** | UDP :45340 | Router DNAT → FE → BE | Unchanged until WG pool |
| **RSSH** | TCP :443 | `ra.apps.f0xx.org` | Bastion may move to dedicated VM |
| **Cast LAN** | UDP/TCP :4123441235 | Device-local | No BE in alpha |
| **Cast WAN** | UDP/TURN/WebRTC | SFU tier (future) | See [20260608_BE_SERVICES_and_infra.md](../20260608_BE_SERVICES_and_infra.md) §SFU |
---
## 7. Service inventory and scaling profile
| Service | Stateful? | Scale axis | Co-location rule |
|---------|-----------|------------|------------------|
| Hub / crashes / tickets | No (PHP) | Horizontal | With API pool |
| Graphs ingest + UI | No | Horizontal | Same pool |
| Builder UI | No | Horizontal | Same pool; triggers build VM |
| Docker build | Ephemeral | Queue workers | **Dedicated** CPU VM |
| MariaDB | Yes | Vertical → replica | **Dedicated**; no ffmpeg |
| Gitea | Yes (git) | Vertical; mirror RO | Low traffic; ops VM OK |
| URL shortener | No | Horizontal | Small; can share ops VM |
| OTA artifacts | Yes (files) | CDN / object store | Split from PHP |
| WireGuard | Yes (peers) | Pool by region | UDP-friendly VM |
| RSSH bastion | Session | Horizontal + sticky | See §12.1 |
| SFU / ffmpeg | Session | Horizontal | **Dedicated** media subnet |
---
## 8. Deployment and promotion pipeline
```text
feature/* or next ──► dev cluster (auto or manual)
▼ QA + PO
staging cluster
▼ soak + sign-off ([ALPHA.md](../ALPHA.md) pattern extended)
prod cluster
```
| Step | Actor | Gate |
|------|-------|------|
| Deploy to **dev** | DEV / CI | Unit tests green |
| Promote to **staging** | PO or release delegate | Integration + e2e smoke |
| Promote to **prod** | PO | Staging soak; [GIT_FLOW.md](../GIT_FLOW.md) tag on `master` for app releases |
**Config rule:** `config.php` and secrets are **environment-specific**; post-sync scripts (e.g. `ensure_url_shortener_prod_config.php`) must be idempotent per environment.
---
## 9. Zero-downtime and load balancing
| Tier | Strategy |
|------|----------|
| **PHP-FPM** | N≥2 VMs behind FE upstream block or cloud LB; `max_fpm_children` tuned per VM |
| **MariaDB** | Primary + read replica; app read/write split optional later |
| **Gitea** | Single primary; maintenance window for upgrades unless Gitea HA adopted |
| **WireGuard** | New peers on least-loaded node; existing peers sticky until session end |
| **Static OTA** | Blue bucket + CDN swap or versioned path (`v0/ota/…`) |
**FE nginx pattern (multi-BE):**
```nginx
upstream androidcast_be {
least_conn;
server 10.7.16.128:80 max_fails=2 fail_timeout=10s;
server 10.7.16.129:80 max_fails=2 fail_timeout=10s;
}
```
Cloud providers: prefer **managed LB** (GCP ILB, Azure Front Door/Application Gateway) when FE moves partial traffic to cloud.
---
## 10. Data and storage
| Data class | Store | Backup |
|------------|-------|--------|
| Relational (crashes, tickets, RA, short links) | MariaDB | Daily logical dump + binlog optional |
| Git mirrors | Gitea disk | `gitea_collect_backup.sh`; off-VM copy |
| OTA / APK / build artifacts | Object store or large volume | Version immutability |
| Cast recordings (future) | Object store + lifecycle policy | Glacier-class after N days |
| Audit / graphs time series | MariaDB or TSDB later | DR §open |
**Rule:** Object store VMs optimize for **disk IOPS and egress**, not CPU.
---
## 11. Simulation lab (Alpine HVMs)
Sysop MAY simulate a cluster on FE hypervisor:
| Property | Value |
|----------|-------|
| Base OS | Alpine 3.x (match prod BE) |
| Pre-install | `nginx`, `php81`, `php81-fpm`, `mariadb`, `git`, `docker`, `wireguard-tools` |
| Network | Private `10.7.0.0/8`**no WAN topology change** |
| Count | 37 HVMs typical for dev cluster |
```text
FE XEN
├── hvm-be-web-dev
├── hvm-be-data-dev
├── hvm-be-ops-dev
└── hvm-be-build-dev
```
**Deliverable (future script):** `orchestration/sim/cluster-up.sh` — out of scope until PO prioritizes; SPEC reserves name.
---
## 12. Capacity planning framework
### 12.1 RSSH / remote access connections
Planning numbers for **single bastion VM** (2 vCPU, 2 GiB RAM, Alpine):
| Metric | Conservative | Target design |
|--------|--------------|---------------|
| Concurrent **SSH** sessions | 50 | 100 with `MaxSessions` tuning |
| Concurrent **heartbeat** devices (poll 17 min) | 2 000 | 5 000 (mostly idle HTTP) |
| Peak **new sessions / min** | 10 | 30 with queue |
| WireGuard **peers** (lab track) | 200 | 500 split across 2 WG nodes |
**Bottleneck order:** MariaDB connection pool → sshd `MaxStartups` → PHP-FPM for heartbeat API → CPU on bastion.
When **staging** exceeds conservative column for 24 h, add **be-vpn-2** before cloud migration.
### 12.2 Cast and media paths (future)
| Mode | Latency | Scale unit | ffmpeg role |
|------|---------|------------|-------------|
| LAN P2P | Lowest | N/A (alpha) | None |
| SFU relay | Low | 1 SFU ≈ 50200 viewers (codec dependent) | Optional simulcast |
| HLS/m3u8 bridge | Mediumhigh | 1 worker ≈ 1030 transcodes | Heavy CPU |
Detailed SFU sizing deferred to owner SFU SPEC ([OPEN_TASKS_GRAPH.md](../OPEN_TASKS_GRAPH.md)).
### 12.3 Network bandwidth and egress
Scale planning MUST include **throughput**, not only CPU/RAM/connections. Hidden bottleneck today: **single FE WAN uplink****FE→BE backhaul****single BE NIC**.
#### Link inventory (baseline)
| Link | Typical role | Metric |
|------|--------------|--------|
| **Router WAN** (`134.17.26.161`) | Public ingress/egress | Mbps sustained + peak; GB/month |
| **FE ↔ BE** (`10.7.0.10``10.7.16.128`) | Proxied HTTP(S), internal sync | Mbps; must exceed sum of BE-facing services |
| **BE origin** | PHP, MariaDB, Gitea, OTA origin | NIC util %; egress GB/month |
| **UDP DNAT** | WireGuard lab | Mbps per active peer × peers |
| **Future CDN origin pull** | OTA, recordings, HLS | Origin Mbps; **CDN egress** billed separately |
#### Per-service bandwidth profile (planning)
| Service | Direction | Order of magnitude (prod planning) | Scale trigger |
|---------|-----------|-----------------------------------|---------------|
| Control APIs (upload, heartbeat) | In ≪ out | 120 Mbps peak; low GB/month | p95 latency, not Mbps |
| Consoles / hub | Out | &lt; 5 Mbps sustained | CDN cache static assets |
| OTA APK / `.otapkg` | Out | **Burst 50500 Mbps** during release | CDN **required** before wide rollout |
| Gitea clone/fetch | Out | 10100 Mbps burst | Mirror geo cache |
| URL shortener redirect | Out | Low | — |
| Cast recordings (F3) | Out | **100 Mbps1 Gbps** per hot object | Object store + CDN; geo replicas |
| SFU relay (F1) | In+out symmetric | **550 Mbps × streams** | Dedicated media NIC / region |
| ffmpeg transcode (F2) | In+out | **10100 Mbps** per job | Worker pool + queue depth |
| Commercial VPN (F4) | UDP bidirectional | **15 Mbps × active tunnels** | WG pool per region |
#### Budget template (PO fills per environment)
| Metric | Dev | Staging | Prod (initial) | Alert |
|--------|-----|---------|----------------|-------|
| WAN egress GB/month | — | — | _TBD_ | 80% of ISP/cloud quota |
| Peak WAN Mbps | — | — | _TBD_ | 70% of measured cap |
| FE→BE peak Mbps | — | — | _TBD_ | sustained &gt; 60% 15 min |
| OTA release peak | — | soak test | CDN absorbs &gt; 90% | origin &gt; 100 Mbps |
| Media egress GB/month | — | — | _TBD_ (F3+) | cost budget |
**DR rule:** Do not add **be-web-2** for bandwidth until CDN/OTA offload is evaluated — horizontal PHP nodes do not fix a saturated WAN link.
---
## 13. Cloud migration (GCP / Azure / other)
**Hybrid recommended** for first cloud phase:
| Stay on-prem (FE + latency-sensitive) | Cloud candidate |
|---------------------------------------|-----------------|
| Gentoo FE TLS (or parallel cloud LB) | Object store (GCS/Azure Blob) |
| WireGuard UDP if DNAT chain kept | Managed MariaDB |
| LAN cast | ffmpeg worker MIG / VMSS |
| | SFU pool (GPU optional) |
**Migration sequence (normative):**
1. Object store for OTA + build artifacts
2. Read-only analytics replica or backup target
3. Staging cluster entirely in cloud (dress rehearsal)
4. Prod PHP tier behind hybrid LB
5. Media plane last (highest risk)
**Egress rule:** Keep **one** canonical public URL per service; DNS/CNAME moves — no app hard-coded IP changes.
**Bandwidth rule:** Model **cloud egress pricing** in phase 1 object-store migration; prefer **CDN in front of origin** for any artifact &gt; 1 MB served to &gt; 100 users.
---
## 14. Global availability and GEO policy
Draft intent: **anyone in the world can use the platform** (latency permitting), while **specific content or features may be excluded by region** — e.g. a recording or OTA channel must **not** be published or downloadable in **Zambia (`ZM`)** when PO sets that policy.
### 14.1 Reach model
```mermaid
flowchart TB
subgraph Users["Global users"]
U1["Americas"]
U2["Europe / Africa"]
U3["Asia-Pacific"]
end
subgraph Edge["Edge layer"]
DNS["GeoDNS / Anycast DNS"]
CDN["CDN + WAF geo rules"]
FE0["Primary FE TLS — on-prem"]
end
subgraph Origin["Origin cluster(s)"]
BE["BE VMs / cloud origin"]
OBJ["Object store"]
MEDIA["SFU / media PoP future"]
end
U1 --> DNS
U2 --> DNS
U3 --> DNS
DNS --> CDN
CDN --> FE0
CDN --> OBJ
FE0 --> BE
BE --> OBJ
MEDIA --> OBJ
```
| Phase | Reach | Latency target (control plane) |
|-------|-------|--------------------------------|
| **Now** | Single FE/BE (EU-centric) | Best-effort worldwide HTTPS |
| **Phase 56** | CDN for static + OTA | p95 &lt; 300 ms TTFB via edge |
| **Future** | Optional regional **media PoP** + signaling | RT cast &lt; 150 ms LAN-equivalent where PoP exists |
**Normative:** Public APIs and consoles remain **globally reachable** unless PO declares a **maintenance** or **sanctions** block at edge (separate from per-content deny).
### 14.2 Segment isolation
| Concept | Definition |
|---------|------------|
| **Segment** | ISO country code, optional region group (`EU`, `AFRICA`, custom list) |
| **Default** | **Allow serve** worldwide for control plane; content inherits tenant default |
| **deny_publish** | Content must not be **made available** in listed segments (upload/mirror/index) |
| **deny_serve** | Existing object must not be **delivered** (HTTP 451 / empty manifest / VPN reject) |
| **allow_only** | Exclusive list — only these segments (stricter catalogues) |
**Example (illustrative):**
```json
{
"resource": "cast_recording:abc123",
"geo_policy": {
"default": "allow",
"deny_serve": ["ZM"],
"deny_publish": ["ZM"]
}
}
```
Same pattern applies to OTA channels, short-link targets (optional), and paid stream entitlements (F5).
| Content class | Typical policy |
|---------------|----------------|
| Crash/ticket consoles | Global (operator VPN/RBAC unchanged) |
| OTA `stable` channel | Global unless PO restricts beta regions |
| Cast recording (F3) | **Per-object** deny segments |
| HLS rebroadcast (F9) | Per-channel geo + copyright rules |
| Commercial VPN exit (F4) | **egress region** choice, not arbitrary bypass of deny_serve |
### 14.3 Enforcement layers
| Layer | Mechanism | When |
|-------|-----------|------|
| **L1 DNS / GeoDNS** | Route to nearest healthy edge | Multi-PoP phase |
| **L2 CDN / WAF** | Geo block or cache vary-by-country | OTA, static, recording download |
| **L3 FE nginx** | `geo` module or auth subrequest | Optional coarse blocks |
| **L4 BE API** | Resolve client country (CDN header `CF-IPCountry`, GeoIP) → **geo_policy** check | Normative for dynamic content |
| **L5 Object store** | Bucket policy / signed URL with segment claim | Large artifacts |
| **L6 App / entitlements** | Mobile checks BE before playback | F3, F5, F9 |
**Headers (normative when behind CDN):** trust `X-Geo-Country` or provider equivalent only from **FE/CDN** — strip at edge from client.
**Audit:** deny_serve events logged to audit table (who, resource, country, time) for operator review.
**Dev/staging:** may use `X-Debug-Country: ZM` header to simulate segments without VPN.
---
## 15. RBAC and operator model evolution
| Phase | Model |
|-------|-------|
| **Now** | Developer-centric; shared `ac_crash_sess`; privilege sets for tickets, RA, short links |
| **Staging scale** | Separate **operator** vs **developer** orgs in Gitea; audit log export |
| **Prod commercial** | Tenant-scoped RBAC; customer admin roles; SSO optional |
Platform MUST support **per-environment** user tables or row-level `environment_id` before multi-tenant commercial features (F5F9).
---
## 16. Commercial and paid services (platform hooks)
No implementation in this SPEC — only **interfaces**:
| Hook | Requirement |
|------|-------------|
| **Billing** | Webhook idempotency; `tenant_id` on all paid resources |
| **Metering** | VPN minutes, storage GB, transcode minutes, **egress GB** — export to metrics |
| **Entitlements** | Mobile app checks BE `/api/v1/entitlements` (future); includes **geo_allowed** flag |
| **Add-ons** | Feature flags per user; GPU queue for masks/background |
Payment provider SHOULD have **no monthly minimum** at low volume (draft: “near 0 for developer”).
---
## 17. Observability and SLO
| Service | Health | SLO (prod, initial) |
|---------|--------|---------------------|
| Control APIs | `/api/…` + upload smoke | 99.5% monthly |
| Consoles | HTTP 200 login page | 99.5% |
| URL shortener | `/api/v1/health` | 99.9% |
| RSSH heartbeat | device poll success rate | 99% |
| Gitea | mirror sync age < 2× interval | Best effort |
**Bandwidth metrics (add to graphs / future central metrics):**
| Metric | Source | Alert |
|--------|--------|-------|
| `wan_egress_mbps` | router or FE interface | &gt; 70% cap 15 min |
| `fe_be_backhaul_mbps` | FE→BE link | &gt; 60% cap |
| `origin_egress_gbytes_month` | nginx / object store | &gt; 80% budget |
| `cdn_cache_hit_ratio` | CDN analytics | &lt; 85% during OTA |
| `geo_deny_serve_total` | BE audit | anomaly spike |
| `sfu_media_mbps` | SFU (future) | per-PoP capacity plan |
Use existing graphs console + future centralized metrics ([GRAFANA_vs_others_graphvis_pivot.md](../GRAFANA_vs_others_graphvis_pivot.md)).
---
## 18. Safety constraints (current topology)
From [bottomline_reminder.txt](../../bottomline_reminder.txt) and [INFRA.md](../INFRA.md):
| Rule | Detail |
|------|--------|
| **No risky WAN changes** | Router `134.17.26.161` → FE `10.7.0.10` chain stays until DR-approved migration |
| **Port 8089** | Janus/other — not androidcast vhost |
| **BE listen 80** | Canonical for FE `proxy_pass` |
| **ssh access** | `ssh alpine-be`, `ssh f0xx-monstro`, `ssh f0xx.org` — inspect `tmp/FE_gentoo`, `tmp/BE_alpine` before infra edits |
| **Simulation only** | New HVMs on intra `10.7.0.0/8`; no public IP experiments without PO |
---
## 19. Phased delivery map
| Phase | Scope | Alpha relation |
|-------|-------|----------------|
| **0** | Document + simulation design (this SPEC/DR) | Parallel to alpha |
| **1** | Split **build** Docker to dedicated VM; OTA on object disk | Post-alpha |
| **2** | **be-web-2** + FE upstream; staging cluster on HVMs | Post-alpha |
| **3** | Managed backup; MariaDB replica | Post-alpha |
| **4** | SFU + ffmpeg workers (separate SPEC) | Post-alpha |
| **5** | Hybrid cloud staging + **CDN for OTA/static** | Strategic |
| **5b** | **Bandwidth dashboards** + egress budgets on graphs | Strategic |
| **6** | Commercial hooks F5F9 | Product-dependent |
| **7** | **GEO policy** enforcement (F10) for recordings/OTA/catalog | After F3 or PO mandate |
---
## 20. Out of scope (this SPEC revision)
- Concrete Terraform/Pulumi modules
- Pricing quotes for GCP/Azure
- Legal/compliance for payments and VPN resale
- Mobile app changes for commercial features
- Replacing MariaDB with Postgres
- Sanctions-law mapping per country — PO/legal owns lists; platform stores **deny segments** only
---
## 21. Related docs
| Doc | Link |
|-----|------|
| Infra topology | [INFRA.md](../INFRA.md) |
| BE service map | [20260608_BE_SERVICES_and_infra.md](../20260608_BE_SERVICES_and_infra.md) |
| Build / Docker | [BUILD_DEPLOY.md](../BUILD_DEPLOY.md) |
| Remote access | [REMOTE_ACCESS_IMPL.md](../REMOTE_ACCESS_IMPL.md), [20260602_REVERSE_SSH_proposals_summary.md](../20260602_REVERSE_SSH_proposals_summary.md) |
| Open tasks graph | [OPEN_TASKS_GRAPH.md](../OPEN_TASKS_GRAPH.md) |
| Alpha priorities | [20260608_ALPHA_PRIORITIES.md](../20260608_ALPHA_PRIORITIES.md) |
| URL shortener | [specs/20100611_3_url_shortener.md](20100611_3_url_shortener.md) |
| Design review | [DRs/20100612_1_scaling.md](../DRs/20100612_1_scaling.md) |
---
## Changelog
| Date | Change |
|------|--------|
| 2026-06-12 | Initial SPEC from draft `20100612_1_scaling.txt` |
| 2026-06-12 | Rev. 2 — network bandwidth (§12.3), global availability + GEO policy (§14), metrics |

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long