# Puente Talent Technical Challenge (`gab27/puente-talent-technical-challenge`) Actor

Unofficial browserless scraper for public X posts by author or tweet ID.

- **URL**: https://apify.com/gab27/puente-talent-technical-challenge.md
- **Developed by:** [gabriel angel villanueva vega](https://apify.com/gab27) (community)
- **Categories:** Automation, Agents, Social media
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-usage

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## X Tweet Scraper

A browserless Apify Actor and HTTP API for extracting public posts from X. It uses Node.js,
strict TypeScript, Apify SDK v3, `undici`, guest tokens, and the internal GraphQL operations that
power X's public web views.

> **Verified status, August 19, 2026:** the project builds, passes 93 automated tests, and has been
> exercised against X locally and in Docker for author timelines, user profiles, and tweet lookup by
> ID. The native Actor was built on Apify and a cloud smoke run returned 10 free-tier results. A
> local live paid-entitlement run returned 100 results. The source repository is published at
> [Bellota22/puente-talent-technical-challenge](https://github.com/Bellota22/puente-talent-technical-challenge).
> Wiring the owner's cloud entitlement, running the residential-proxy benchmark, and publishing the
> currently private Actor remain deployment tasks. `searchTerms` is rejected because guest
> authentication cannot access `SearchTimeline`.

### What Is Implemented

- Native Apify Actor lifecycle, input/output schemas, Dataset output, KVS state, and migration hooks.
- Public author timelines and tweet lookup through X GraphQL without Playwright, Puppeteer, or
  Selenium.
- Strict normalization, AND-combined filters, deduplication, cursor pagination, and bounded retries.
- An authoritative free-tier gate that emits at most 10 tweets unless a trusted resolver grants paid
  access.
- A Fastify API with executable OpenAPI 3.0.3 documentation and sanitized structured logs.
- A transactional SQLite read model for the standalone HTTP mode.
- Docker Compose, a one-command Bash workflow, Prettier with two-space indentation, 93 tests, and a
  Node.js 24 GitHub Actions verification workflow.

The original assignment is available in [senior-x-scraper-test-v2.pdf](senior-x-scraper-test-v2.pdf).
The [challenge compliance matrix](#technical-challenge-compliance) distinguishes completed,
partial, and deployment-dependent work.

### Quick Start

Requirements: Node.js 22.16.0 or newer and npm 10 or newer. Node.js 24 LTS is recommended and
matches the Docker image.

```bash
npm ci
npm run verify
```

The repository uses Prettier with spaces and a visual tab width of two characters:

```bash
npm run format
npm run format:check
```

A local Actor run without an `INPUT` record uses the safe default target
`fromUsers: ["apify"]`:

```bash
npm start
```

Local Apify output is written to `storage/datasets/default`; the run summary is written to
`storage/key_value_stores/default/OUTPUT.json`. To use a different input, place the contents of
[examples/input.json](examples/input.json) in
`storage/key_value_stores/default/INPUT.json` before starting the Actor.

### HTTP API And Swagger

The HTTP adapter reuses the same application use cases as the Actor:

```bash
npm run build
npm run start:http
```

The API listens at `http://127.0.0.1:3000` by default.

| Method | Route                         | Purpose                                             |
| ------ | ----------------------------- | --------------------------------------------------- |
| `GET`  | `/health`                     | Process liveness.                                   |
| `GET`  | `/ready`                      | Admission capacity and SQLite readiness.            |
| `POST` | `/v1/scrapes`                 | Execute the complete challenge input.               |
| `GET`  | `/v1/tweets/:tweetId`         | Hydrate one public tweet.                           |
| `GET`  | `/v1/users/:username`         | Return a normalized public profile.                 |
| `GET`  | `/v1/users/:username/tweets`  | Scrape one author's timeline.                       |
| `GET`  | `/v1/storage/tweets`          | List stored tweets with keyset pagination.          |
| `GET`  | `/v1/storage/tweets/:tweetId` | Read one stored tweet without contacting X.         |
| `GET`  | `/v1/storage/runs`            | List runs and their sanitized inputs.               |
| `GET`  | `/v1/storage/stats`           | Return counts, WAL mode, and SQLite schema version. |
| `GET`  | `/docs/`                      | Interactive Swagger UI.                             |
| `GET`  | `/docs/json`                  | OpenAPI 3.0.3 as JSON.                              |
| `GET`  | `/docs/yaml`                  | OpenAPI 3.0.3 as YAML.                              |

Open [Swagger UI](http://127.0.0.1:3000/docs/) to execute routes with ready-to-run example values.
Loopback mode does not require a service API key. The **Authorize** dialog accepts `x-api-key`,
`x-storage-token`, and an optional Apify token used only to resolve entitlement.

```bash
curl -X POST http://127.0.0.1:3000/v1/scrapes \
  -H "content-type: application/json" \
  -d '{"fromUsers":["apify"],"maxResults":10}'
```

Without an authoritative entitlement, a run is treated as free and cannot emit more than 10
tweets. Full configuration is documented in [.env.example](.env.example). A non-loopback bind
requires an `HTTP_API_KEY` of at least 32 characters. Request-selected proxy configuration is
rejected unless a trusted deployment explicitly enables `HTTP_ALLOW_INPUT_PROXY=true`.

### Docker Compose

[docker-compose.yml](docker-compose.yml) is the recommended way to review the complete standalone
API. Compose preserves the Actor batch `CMD` in the `Dockerfile`, but starts `dist/server.js` for
Fastify and Swagger. It publishes only to `127.0.0.1`, runs as a non-root user, uses a read-only root
filesystem, and persists SQLite and local Apify storage in named volumes.

```bash
bash scripts/project.sh up
bash scripts/project.sh credentials
bash scripts/project.sh logs
bash scripts/project.sh down
```

`up` builds both services, creates local secrets under `.secrets/`, and waits for health checks.
`credentials` prints the values used by Swagger. `down` preserves named volumes. The API and signed
entitlement sidecar are a local review topology; the published batch Actor uses Dataset/KVS and does
not expose Swagger. See [the deployment guide](docs/DEPLOYMENT.md) for PowerShell instructions,
paid testing, secret handling, all helper commands, and cleanup behavior.

### Architecture

The project uses hexagonal architecture, also known as ports and adapters, with domain,
application, and infrastructure boundaries. The codebase is small enough that a dependency
injection framework would add little value; [src/runtime.ts](src/runtime.ts) is the explicit
composition root.

```mermaid
flowchart LR
    Actor[Apify Actor input] --> UseCase[ScrapeTweetsUseCase]
    HTTP[Fastify endpoints] --> UseCase
    UseCase --> XPort[XDataSource port]
    XPort --> XClient[X GraphQL HTTP client]
    XClient --> Guest[Guest token + proxy session]
    XClient --> Registry[Operation registry]
    UseCase --> Filter[AND filters]
    Filter --> Gate[EntitlementGatedResultSink]
    Gate --> Dataset[Apify Dataset]
    Gate --> SQLite[SQLite transactional read model]
    Gate --> Response[Current HTTP response]
    Entitlement[Remote service or protected KVS] --> Gate
    State[Apify persisted state] --> UseCase
    SQLite --> Admin[Protected storage endpoints]
```

#### SOLID Decisions

- **Single responsibility:** validation, filtering, normalization, networking, entitlement,
  persistence, and HTTP transport live in separate modules.
- **Open/closed:** another data source, result sink, or entitlement provider can implement a port
  without changing the use case.
- **Liskov substitution:** Apify Dataset, SQLite, and the in-memory sink honor the same
  `ResultSink` contract; tests use substitutes with the same observable behavior.
- **Interface segregation:** `XDataSource`, `ResultSink`, `EntitlementResolver`,
  `TweetReadRepository`, `ScrapeRunRepository`, `ProxyUrlProvider`, `Clock`, and `Logger` are small
  contracts.
- **Dependency inversion:** `ScrapeTweetsUseCase` depends on those ports, never directly on Apify,
  Fastify, or `undici`.

#### Request Flow

1. `src/main.ts` initializes the Apify runtime and validates input through the platform schema and
   Zod.
2. Runner `userId`, `actorId`, and `actorRunId` come from the execution environment, never from
   input.
3. The entitlement resolver calls a signed service or private KVS. Failure, missing configuration,
   or unknown identity fails closed to free tier.
4. Each author handle is resolved through `UserByScreenName`; the resulting ID feeds `UserTweets`
   or `UserTweetsAndReplies`. Explicit tweet IDs use `TweetResultByRestId`.
5. A guest token is acquired with the same proxy session used for GraphQL. Each author paginates
   sequentially, while independent authors and tweet IDs use bounded concurrency. A 403 or 429
   rotates both token and sticky proxy session.
6. Each response is parsed, normalized, schema-validated, filtered with AND semantics, and sent to
   the protected result sink.
7. The sink serializes deduplication and capacity reservation. Concurrency, a large `maxResults`, or
   extra input fields cannot bypass the cap.
8. Actor mode pushes tweets to Dataset. HTTP mode commits accepted data in short SQLite
   transactions; network calls never run inside a database transaction.
9. The summary reports `succeeded`, `partial`, or `failed`, including truncation reasons, discarded
   rows, and completed/failed/pending targets. An all-target failure becomes HTTP 502, not an
   optimistic 200 or misleading 404.

### X GraphQL

X's public web client calls private GraphQL operations identified by an `operationName` and a
versioned `queryId`. This project reproduces only the public guest flow over HTTP:

- `UserByScreenName` resolves a handle and supplies normalized author fields.
- `UserTweets` reads a public author's recent timeline.
- `UserTweetsAndReplies` is selected when `includeReplies` is enabled.
- `TweetResultByRestId` hydrates a single public tweet.

`SearchTimeline` is intentionally unsupported because X requires an authenticated user session.
The Actor does not use personal accounts or attempt to bypass that boundary; non-empty
`searchTerms` produces a clear validation error.

Query IDs are not a supported public API and can change without notice. The default registry is a
reviewed snapshot committed in [operations-snapshot.ts](src/infrastructure/x/operations-snapshot.ts)
for deterministic builds. `X_OPERATION_REGISTRY_URL` enables an optional remote registry whose
payload is validated, coalesced, rate-limited by cooldown, bounded by timeout, and capped at 2 MiB.

#### How Guest Operations Were Identified

1. Public X web-client operations were compared with metadata maintained by
   `fa0311/twitter-openapi`: operation names, query IDs, variables, and feature flags.
2. A guest session was activated through `/1.1/guest/activate.json` with the public web-client
   bearer, keeping guest token and proxy IP together as one session.
3. Candidate operations were replayed directly over HTTP. Only operations returning public data
   without account cookies were accepted.
4. Node variants, cursors, and tombstones were checked against live responses before defining the
   parser and normalization contracts.
5. `SearchTimeline` was excluded after confirming its authenticated-session requirement.
6. Approved IDs were committed to the snapshot. Remote refresh stays opt-in because a mutable
   third-party source must not change production behavior silently.

`fa0311/twitter-openapi` is an unofficial research reference, not a runtime dependency or an
official X API. The snapshot makes builds reproducible; it cannot make X's internal protocol stable.

### Entitlement And Threat Model

The Actor supports two authoritative sources, neither accepted from input:

1. `ENTITLEMENTS_ENDPOINT` plus `ENTITLEMENTS_SIGNING_SECRET`: an HMAC-signed POST containing
   `actorId`, `actorRunId`, `runnerUserId`, timestamp, and nonce.
2. `ENTITLEMENTS_KVS_ID`: the owner's private KVS with an `ENTITLEMENTS` record shaped as
   `{ "paidUserIds": ["user-id"] }`.

Secrets and KVS access belong in the official Actor's secure environment. A source fork does not
inherit them and therefore fails closed. A user can remove limits from their own fork of public
code; no check embedded in a public repository can prevent that. The defensible boundary is that a
fork cannot impersonate the commercial service or acquire its entitlements.

In HTTP mode, `Authorization: Bearer <APIFY_TOKEN>` is sent only to `/v2/users/me` to resolve the
runner identity. It is cached by hash with a bounded TTL/LRU policy and is never logged, persisted,
or sent to X. `HTTP_API_KEY` controls access to the service but does not grant paid entitlement.

### Storage

The standalone HTTP server uses `data/x-tweet-scraper.sqlite` by default. Actor batch mode does not:
an Actor may migrate between hosts, so Dataset and KVS are its authoritative durable stores.

There is one logical SQLite database. Files ending in `-wal` and `-shm` are SQLite runtime sidecars,
not extra databases:

- `x-tweet-scraper.sqlite` is the main database.
- `x-tweet-scraper.sqlite-wal` holds committed write-ahead-log pages awaiting checkpoint.
- `x-tweet-scraper.sqlite-shm` coordinates readers and the writer using shared memory.

Do not delete or copy these files individually while the server is running. A clean shutdown
checkpoints and truncates the WAL. All `data/*.sqlite` files are ignored by Git because they are
local databases or exported snapshots, not application source. In Compose, the live database is
inside the named volume `x-tweet-scraper_scraper_data`; a host file under `./data` is separate and
may be empty. Inspect or export the real volume database with:

```bash
bash scripts/project.sh db-stats
bash scripts/project.sh db-export
```

`db-export` uses `node:sqlite backup()` while the service remains online and writes a consistent,
dated snapshot under `./data/`. Open that snapshot in DB Browser for SQLite.

The implementation is split by responsibility even though SQLite stores one physical database:

- `sqlite-scrape-store.ts`: adapter facade implementing application ports.
- `sqlite-schema.ts`: migrations, tables, and indexes.
- `sqlite-statements.ts`: prepared statements.
- `sqlite-codecs.ts`: row mapping, validation, and opaque cursors.
- `sqlite-utils.ts`: transactions, permissions, and SQLite conversions.

SQLite uses `STRICT` tables, foreign keys, prepared statements, WAL, `BEGIN IMMEDIATE`,
observation-ordered UPSERTs, keyset pagination, and configurable 30-day retention. Abandoned runs
are marked `interrupted` at startup. The persisted-input allowlist excludes proxy configuration,
authorization, Apify/X tokens, guest cookies, HMAC signatures, upstream responses, and raw
exceptions. SQLite is not encrypted by this application; use an encrypted volume and the backup
policy in [docs/STORAGE.md](docs/STORAGE.md).

Storage routes are administrative. When exposed, they require an independent
`STORAGE_READ_TOKEN` through `x-storage-token`; otherwise they remain loopback-only or disabled.

### Capacity And Deployment Modes

- **Apify Actor:** every run uses an isolated process, Dataset, and KVS. It does not use SQLite.
  Concurrency and scale are governed by Apify account, memory, proxy, and platform limits.
- **Standalone HTTP:** one process defaults to exactly two active remote scrape jobs through
  `HTTP_MAX_CONCURRENT_SCRAPES=2`. A third simultaneous scrape is rejected immediately with HTTP
  429; it is not placed in an unbounded queue.
- **SQLite:** `node:sqlite` is synchronous and the process shares one connection. WAL lets readers
  coexist with the writer, but SQLite still has one writer and does not provide horizontal scale.
- **Memory:** each synchronous HTTP scrape retains up to `maxResults` normalized items for its
  response. Large paid exports are better served by an Actor Dataset.

No load test has been performed, so this repository makes no RPS or SLA claim. The observed
9.385-second live run for 100 results implies only a rough best-case ceiling of about 12 comparable
heavy runs per minute at concurrency two (`2 * 60 / 9.385`), before upstream throttling, retries,
proxy latency, storage contention, or CPU/memory overhead. It is an illustration, not a capacity
commitment. Horizontal HTTP scale requires a managed database plus distributed admission and rate
limiting; the natural scale-out path for this challenge is isolated Apify Actor runs.

### Deploy To Apify

Run the complete preflight before publishing:

```bash
npm ci
npm run verify
```

Use the repository as the Actor source in Apify Console:

1. Open **Actors**, select **Develop new**, and choose **GitHub** as the source.
2. Connect GitHub and select
   `Bellota22/puente-talent-technical-challenge` with `master` as the default branch.
3. Confirm that Apify detects [.actor/actor.json](.actor/actor.json) and the root
   [Dockerfile](Dockerfile), then start the build.
4. Confirm that the build finishes and the input, Dataset, and output schemas render correctly.

The current private deployment is available to authorized collaborators at
[Apify Console](https://console.apify.com/actors/kbbsiDsoqSdAeTkza). Its stable Actor ID is
`kbbsiDsoqSdAeTkza`; its API alias is `gab27~puente-talent-technical-challenge`.

A URL such as `https://<run-host>.runs.apify.net` is only the temporary container URL for one run.
This Actor is a batch job and exits after writing Dataset and KVS output, so opening that URL after
`SUCCEEDED` correctly reports that the run has already finished. It is not the Actor page or a
result URL. Use the stable Console link above, the run link from the **Runs** tab, or the Dataset API.

The API token authenticates the runner but does not itself grant this application's paid
entitlement. For an owner-only smoke test that preserves Apify's Limited permissions:

1. Resolve the runner ID with `GET https://api.apify.com/v2/users/me` using the same Bearer token.
2. In **Code > Environment variables**, set secret `ENTITLEMENTS_KVS_ID` to a new KVS name such as
   `puente-owner-entitlements-v1`. Leave `ENTITLEMENTS_ENDPOINT` and
   `ENTITLEMENTS_SIGNING_SECRET` unset.
3. Build and run once. The Actor creates the named KVS; this bootstrap run remains free because the
   allow-list record does not exist yet.
4. Open that Actor-created store under **Storage > Key-value stores** and add a JSON record with key
   `ENTITLEMENTS`:

```json
{
  "paidUserIds": ["YOUR_APIFY_USER_ID"]
}
```

5. Run again with the same user's token. The ID in `paidUserIds` is the exact `data.id` returned by
   `/v2/users/me`, not an API token, username, or email address.

This bootstrap order matters: a Limited-permission Actor cannot read an arbitrary pre-existing user
KVS, but it retains access to a named store that it created. Without a reachable authoritative KVS
or signed endpoint, the Actor intentionally fails closed to the free cap of 10.

For third-party public runners, use the signed remote entitlement endpoint as the production-safe
path. Access to an owner's private KVS depends on the credentials available to each run, so the KVS
setup must not be assumed to authorize every customer execution automatically.

Run this smoke input from Apify Console:

```json
{
  "fromUsers": ["apify"],
  "tweetIds": [],
  "searchTerms": [],
  "hashtags": [],
  "includeReplies": false,
  "includeRetweets": false,
  "sortBy": "latest",
  "maxResults": 100,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

Verify both output surfaces after the run:

1. **Dataset** contains normalized tweet rows and never exceeds the authorized cap.
2. The default KVS **OUTPUT** record contains the honest run summary, including `status`,
   `requested`, `fetched`, `matched`, `pushed`, `limited`, `entitlement`, `partialReasons`, and
   target counts.

Repeat with the residential proxy group required by the challenge. Record wall-clock time from the
first request through the 100th Dataset push, memory, compute units, proxy transfer, and Dataset item
count. Save the public Actor URL and one successful run URL as review evidence. Never place an API
token in the repository, input example, screenshots, or video.

The recommended public interface is the batch Actor because every invocation has an isolated run,
Dataset, KVS state, and entitlement counter. Apify exposes it through:

```text
POST https://api.apify.com/v2/actors/kbbsiDsoqSdAeTkza/runs
POST https://api.apify.com/v2/actors/kbbsiDsoqSdAeTkza/run-sync-get-dataset-items
GET  https://api.apify.com/v2/datasets/<datasetId>/items
```

Send the token in `Authorization: Bearer ...`, never in Actor input or a committed URL. The example
[examples/apify-100.json](examples/apify-100.json) is ready for the synchronous endpoint:

```bash
read -rsp 'Apify API token: ' APIFY_TOKEN && echo
curl --fail-with-body --location \
  'https://api.apify.com/v2/actors/kbbsiDsoqSdAeTkza/run-sync-get-dataset-items?clean=true&limit=100' \
  -H "Authorization: Bearer $APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  --data-binary @examples/apify-100.json
unset APIFY_TOKEN
```

The synchronous endpoint returns Dataset items directly but has a maximum HTTP wait of 300 seconds.
Fastify remains useful for local review or a separate service deployment. Standby mode is disabled
because sharing one process across callers complicates per-run isolation and commercial caps.

An optional CLI deployment from the repository root is also available:

```bash
npx apify-cli login
npx apify-cli push
```

This README does not claim that the cloud Actor has already been deployed; update the verification
and compliance sections only after the Apify build and smoke run succeed. The complete procedure,
including the residential input and output checklist, is in
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).

### Repository Tour

Read the implementation in this order:

1. [src/domain/input.ts](src/domain/input.ts): strict Zod input, defaults, required targets, dates,
   and explicit `searchTerms` rejection.
2. [src/domain/tweet.ts](src/domain/tweet.ts): exact output contract, including required `null`
   fields and rejection of accidental properties.
3. [src/application/ports.ts](src/application/ports.ts): layer boundaries.
4. [src/application/scrape-tweets.ts](src/application/scrape-tweets.ts): target orchestration,
   pagination, filtering, bounded work, errors, and summary.
5. [src/application/gated-result-sink.ts](src/application/gated-result-sink.ts): serialized
   entitlement gate, deduplication, and emission cap.
6. [src/infrastructure/x/x-graphql-client.ts](src/infrastructure/x/x-graphql-client.ts): GraphQL
   HTTP calls, retry budget, jittered backoff, and 403/429 rotation.
7. [src/infrastructure/x/guest-session-manager.ts](src/infrastructure/x/guest-session-manager.ts):
   binds a guest token to one proxy session.
8. [src/infrastructure/x/operation-registry.ts](src/infrastructure/x/operation-registry.ts): checked-in
   snapshot plus bounded opt-in remote refresh.
9. [src/infrastructure/x/response-parser.ts](src/infrastructure/x/response-parser.ts) and
   [tweet-normalizer.ts](src/infrastructure/x/tweet-normalizer.ts): cursor/node extraction and final
   schema normalization.
10. [src/application/storage.ts](src/application/storage.ts): segregated storage ports, query types,
    and persisted-input allowlist.
11. [src/infrastructure/storage/sqlite-scrape-store.ts](src/infrastructure/storage/sqlite-scrape-store.ts):
    SQLite facade delegating schema, SQL, codecs, and utilities to adjacent modules.
12. [src/infrastructure/security/redaction.ts](src/infrastructure/security/redaction.ts): recursive
    redaction and bounded error messages.
13. [src/config.ts](src/config.ts): typed HTTP configuration and external-bind secret rules.
14. [src/main.ts](src/main.ts): Actor adapter, `Actor.init()`, input, migration state, Dataset, and
    output.
15. [src/http-server.ts](src/http-server.ts): Fastify adapter, admission, deadline, authentication,
    access logs, and HTTP persistence.
16. [src/openapi.ts](src/openapi.ts): shared OpenAPI schemas, errors, and executable Swagger examples.
17. [.actor/actor.json](.actor/actor.json), [INPUT\_SCHEMA.json](INPUT_SCHEMA.json),
    [dataset\_schema.json](.actor/dataset_schema.json), and
    [output\_schema.json](.actor/output_schema.json): Apify platform contract.

For deeper review, see [docs/ARCHITECTURE\_REVIEW.md](docs/ARCHITECTURE_REVIEW.md) and
[docs/STORAGE.md](docs/STORAGE.md).

### Verification Evidence

Local and Docker checks performed on August 19, 2026, without a proxy unless specified:

| Path                                                        | Observed result                                                       |
| ----------------------------------------------------------- | --------------------------------------------------------------------- |
| `npm run verify`                                            | 93/93 tests, Prettier, strict TypeScript, and Node build passed.      |
| `npm audit --omit=dev`                                      | Zero production dependency vulnerabilities.                           |
| `docker compose up --build --wait`                          | API and sidecar healthy on `apify/actor-node:24`.                     |
| Container user, root, and secrets                           | UID 100, read-only root, no mounted keys in `docker inspect`.         |
| `/docs/`, `/docs/json`, and `/docs/yaml`                    | Swagger UI and OpenAPI 3.0.3 with executable examples.                |
| SQLite migration, WAL, rollback, UPSERT, cursor, and secret | Persistent volume, transactions, and secret canaries verified.        |
| `apify-cli validate-schema` and regression tests            | Input, Draft-07 Dataset, Output, and local references pass.           |
| Actor, `fromUsers: ["apify"]`, `maxResults: 10`             | 40 fetched, 20 matched, 10 emitted, zero errors, 2.824 seconds.       |
| Docker `GET /v1/users/apify`                                | `401` without a key; normalized profile with the correct key.         |
| Docker `POST /v1/scrapes`, `maxResults: 3`                  | Three items, `succeeded`, persisted in SQLite, zero errors.           |
| Docker `POST /v1/scrapes`, `maxResults: 1000`, free         | 10 items, `partial`, `limited: true`, `reason: free_tier`.            |
| Remote resolver against HMAC sidecar                        | Paid tier, signature, expiry, and replay protection verified.         |
| Live runtime, paid entitlement, `maxResults: 100`           | 100 items in 9.385 s; 222 fetched, 104 matched, no truncation/errors. |
| `GET /v1/tweets/2090084776988262560`                        | Hydrated tweet, 16 root fields, one emitted result, zero errors.      |

The 100-item live test verifies pagination, cardinality, and paid gating. It does not replace the
challenge benchmark inside Apify with a residential proxy, measured from the first request through
the 100th Dataset push.

### Technical Challenge Compliance

| Requirement                                          | Status                              | Evidence or note                                                                     |
| ---------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------ |
| Node.js, strict TypeScript, Apify SDK v3             | Complete                            | `package.json`, `tsconfig.json`, and `src/main.ts`.                                  |
| Native Actor, schemas, Dataset, and Docker           | Complete and built                  | Validated schemas and Node 24 image.                                                 |
| No browser automation                                | Complete                            | `undici` only; no Playwright, Puppeteer, or Selenium.                                |
| Author timeline, tweet by ID, and profile            | Complete and tested                 | Guest operations and local/Docker smoke tests.                                       |
| Inputs, AND filters, and inclusive dates             | Complete                            | Strict Zod contract and `domain/filter.ts`.                                          |
| `searchTerms`                                        | Stretch goal not implemented        | Explicit rejection; `SearchTimeline` requires user authentication.                   |
| `sortBy: latest`                                     | Partial across multiple targets     | Per-timeline order; no global chronological merge yet.                               |
| `sortBy: top`                                        | Transparent partial behavior        | Ranking over a bounded scan; summary reports `ranking_window`.                       |
| Exact output, `null`, HTML, and expanded links       | Complete and tested                 | Includes normal and media `t.co` URL expansion.                                      |
| Dataset, cursors, and global deduplication           | Complete                            | Apify adapter, per-author state, and global gate.                                    |
| Guest token, rotation, 403/429/5xx retry, and jitter | Complete and tested                 | Direct GraphQL client tests and deterministic substitutes.                           |
| Configurable Apify Proxy                             | Complete in code                    | Sticky session per target with coordinated rotation.                                 |
| Migration and resume                                 | Implemented; live test pending      | `Actor.useState`, `persistState`, and `migrating`.                                   |
| Authoritative fail-closed free cap                   | Complete; production source pending | Signed local sidecar; owner endpoint/private KVS needed on Apify.                    |
| Required tests and additional coverage               | Complete                            | 93/93 across domain, network, HTTP, OpenAPI, security, and SQLite.                   |
| Documented guest-operation research                  | Complete                            | Reproducible method and checked-in snapshot described above.                         |
| Public source repository                             | Complete                            | [GitHub repository](https://github.com/Bellota22/puente-talent-technical-challenge). |
| Apify cloud build and free-tier smoke run            | Complete                            | Private Actor built and emitted 10 capped Dataset items.                             |
| Public Store page and shareable run URL              | Pending publication                 | Publish or share the private Actor with reviewers.                                   |
| Residential benchmark for 100 results                | Partial                             | 100 live results verified; repeat on Apify residential proxy.                        |
| Cost per 1,000 results and completion webhook        | Optional work pending               | Measure or implement from real Apify runs; do not estimate blindly.                  |

The core evaluation surface is implemented. The submission should not be described as fully closed
until the Actor is published, the owner's entitlement source is configured, and the residential
benchmark is attached.

### Limitations And Next Steps

- X may change query IDs, feature flags, or response shapes without notice. Monitor public web
  bundles and update the reviewed snapshot deliberately.
- `sortBy: latest` preserves order within each scanned timeline, but concurrently processed targets
  are not merged into one globally stable chronology. A strict merge needs round-robin cursor
  scheduling and a larger buffer.
- `sortBy: top` ranks only the bounded scanned window and reports that limitation in the summary.
- `node:sqlite` is synchronous and the HTTP response retains its own result set in memory. Use
  Dataset/KVS or a managed database for large exports, high RPS, or multiple replicas.
- SQLite does not encrypt data at rest and must not be placed on NFS/SMB. Protect volumes, backups,
  retention, and administrative endpoints.
- Public HTTP deployment needs gateway-level distributed quotas, key rotation, and audit trails in
  addition to the process-local admission limit.
- Protected, suspended, deleted, or unavailable accounts/posts can yield no items and a target error
  without terminating unrelated targets.
- Before operating at scale, review X terms, robots guidance, lawful basis, data minimization,
  retention/deletion, privacy, and customer-specific contractual limits.
- Do not publish a time-to-100 or cost-per-1,000 figure until it is measured in Apify with the
  required residential proxy.

### References

Official documentation:

- [Apify SDK for JavaScript quick start](https://docs.apify.com/sdk/js/docs/3.4/introduction/quick-start)
- [Actor lifecycle](https://docs.apify.com/sdk/js/docs/concepts/actor-lifecycle)
- [Actor definition](https://docs.apify.com/actors/development/actor-definition/actor-json)
- [Input schema](https://docs.apify.com/actors/development/actor-definition/input-schema/specification/v1)
- [Dataset schema](https://docs.apify.com/storage/dataset-schema)
- [Output schema](https://docs.apify.com/actors/development/actor-definition/output-schema)
- [State persistence and migration](https://docs.apify.com/actors/development/builds-and-runs/state-persistence)
- [Apify storage and retention](https://docs.apify.com/storage)
- [Secret environment variables](https://docs.apify.com/actors/development/programming-interface/environment-variables)
- [Proxy configuration](https://docs.apify.com/sdk/js/reference/class/ProxyConfiguration)
- [Synchronous Actor endpoint](https://docs.apify.com/api/v2/actor-run-sync-get-dataset-items-post)
- [Apify SDK repository](https://github.com/apify/apify-sdk-js)
- [Crawlee repository](https://github.com/apify/crawlee)
- [Official Actor templates](https://github.com/apify/actor-templates)
- [Fastify Swagger](https://github.com/fastify/fastify-swagger)
- [Fastify Swagger UI](https://github.com/fastify/fastify-swagger-ui)
- [Prettier configuration](https://prettier.io/docs/configuration)
- [Node.js `node:sqlite`](https://nodejs.org/api/sqlite.html)
- [SQLite write-ahead logging](https://www.sqlite.org/wal.html)

Unofficial reference for X's volatile internal protocol:

- [fa0311/twitter-openapi](https://github.com/fa0311/twitter-openapi), used as a metadata research
  source rather than a code dependency or official X API.

# Actor input Schema

## `fromUsers` (type: `array`):

X handles without @.

## `tweetIds` (type: `array`):

Decimal tweet IDs to hydrate.

## `searchTerms` (type: `array`):

Guest SearchTimeline is auth-walled. Non-empty values are rejected explicitly.

## `hashtags` (type: `array`):

Every listed hashtag must be present; omit #.

## `since` (type: `string`):

Inclusive ISO-8601 lower date/time bound.

## `until` (type: `string`):

Inclusive ISO-8601 upper date/time bound; a date-only value includes the full UTC day.

## `language` (type: `string`):

ISO-639-1 language code.

## `minLikes` (type: `integer`):

Only emit tweets with at least this many likes.

## `minRetweets` (type: `integer`):

Only emit tweets with at least this many retweets.

## `minReplies` (type: `integer`):

Only emit tweets with at least this many replies.

## `onlyVerified` (type: `boolean`):

Only emit tweets whose author carries an X verification label.

## `mediaType` (type: `string`):

Post-filter by the normalized tweet entities.

## `includeReplies` (type: `boolean`):

Use the tweets-and-replies timeline and allow replies to count as results.

## `includeRetweets` (type: `boolean`):

Allow retweets to count as results.

## `sortBy` (type: `string`):

Latest uses timeline order; top ranks the bounded scanned window by engagement.

## `maxResults` (type: `integer`):

Requested maximum. The authoritative free-tier emission cap is 10.

## `proxyConfiguration` (type: `object`):

Apify Proxy or custom proxy settings.

## Actor input object example

```json
{
  "fromUsers": [
    "apify"
  ],
  "tweetIds": [],
  "searchTerms": [],
  "hashtags": [],
  "onlyVerified": false,
  "mediaType": "any",
  "includeReplies": false,
  "includeRetweets": false,
  "sortBy": "latest",
  "maxResults": 100,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `tweets` (type: `string`):

No description

## `summary` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {};

// Run the Actor and wait for it to finish
const run = await client.actor("gab27/puente-talent-technical-challenge").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {}

# Run the Actor and wait for it to finish
run = client.actor("gab27/puente-talent-technical-challenge").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{}' |
apify call gab27/puente-talent-technical-challenge --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,gab27/puente-talent-technical-challenge"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/kbbsiDsoqSdAeTkza/builds/acOkxEmeLf7geKezS/openapi.json
