mirror of
https://github.com/superdesigndev/treg.git
synced 2026-10-02 03:24:35 +08:00
feat(media): treg host reference-file hosting + catalog-id-with-path error
Two fixes from generating an 18 s Seedance 2.5 take through treg on 2026-09-14.
`treg host <file>` / `POST /media` / `GET /m/{token}`: AIGC endpoints take reference
images, voice clips and videos as public URLs the vendor fetches, and every paste host
tried failed a vendor probe (catbox unreachable from reAPI's fal-backed probe, tmpfiles
serving HTML, uguu timing out) - five submissions for one accepted task. Bytes live in a
new `media` table (migration 0037), served by a 192-bit opaque token, 30 MB per file,
300 MB per org per 24 h, 7-day TTL, image/audio/video only, refused in the sandbox, not
metered. Expiry is swept inline on upload.
`treg call reapi.tasks.get tasks/<id>` used to answer "no tool 'reapi.tasks.get' in this
org": the own-tool shape applied to a catalog id sent the caller to the wrong half of
treg. It now answers 400 naming the endpoint's parameter slots and the --query form.
Fragments: new architecture/media.md; interface/cli.md, interface/api.md. Front door:
llms.txt, skill.md (+ generated plugin copies), USAGE.md. Snapshots regenerated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYmLQ8AcvGcDM95MRNjodb
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
b116e2f706
commit
c8d51c5e21
@@ -78,6 +78,7 @@ Regenerate via `scripts/build-map.py`.
|
||||
| `src/treg/alembic/versions/0034_managed_api_keys.py` | architecture/data-model.md, ops/deploy.md |
|
||||
| `src/treg/alembic/versions/0035_default_key_generation.py` | architecture/data-model.md, ops/deploy.md |
|
||||
| `src/treg/alembic/versions/0036_activity_key_indexes.py` | architecture/data-model.md, ops/deploy.md |
|
||||
| `src/treg/alembic/versions/0037_media_hosting.py` | architecture/media.md |
|
||||
| `src/treg/analytics.py` | architecture/data-model.md |
|
||||
| `src/treg/api.py` | architecture/archive.md, architecture/money.md, architecture/multi-tenancy.md, architecture/proxy-model.md, architecture/super-admin.md, interface/api.md, interface/dashboard.md, interface/landing-sandbox.md, interface/seo.md |
|
||||
| `src/treg/application/__init__.py` | architecture/import-boundaries.md |
|
||||
@@ -105,6 +106,7 @@ Regenerate via `scripts/build-map.py`.
|
||||
| `src/treg/application/call/types.py` | architecture/import-boundaries.md, architecture/proxy-model.md, interface/api.md |
|
||||
| `src/treg/application/connect.py` | architecture/auth-secrets.md, architecture/composition.md, guides/expanding-a-category.md, interface/api.md |
|
||||
| `src/treg/application/feedback.py` | architecture/feedback.md |
|
||||
| `src/treg/application/media.py` | architecture/media.md |
|
||||
| `src/treg/application/onboard.py` | interface/api.md |
|
||||
| `src/treg/application/onboard/__init__.py` | interface/landing-sandbox.md, interface/onboarding.md |
|
||||
| `src/treg/application/onboard/demo.py` | interface/onboarding.md |
|
||||
@@ -345,7 +347,7 @@ Regenerate via `scripts/build-map.py`.
|
||||
| `src/treg/maintenance.py` | architecture/data-model.md, ops/deploy.md |
|
||||
| `src/treg/mcp.py` | architecture/catalog.md, architecture/instagram-oauth.md, architecture/mcp-oauth.md |
|
||||
| `src/treg/mcp_install.py` | interface/skill.md |
|
||||
| `src/treg/models.py` | architecture/data-model.md, architecture/money.md, architecture/multi-tenancy.md, interface/enrich-arena.md |
|
||||
| `src/treg/models.py` | architecture/data-model.md, architecture/media.md, architecture/money.md, architecture/multi-tenancy.md, interface/enrich-arena.md |
|
||||
| `src/treg/oauth.py` | architecture/auth-secrets.md |
|
||||
| `src/treg/oauth_providers.py` | architecture/auth-secrets.md, architecture/harvestapi.md, guides/expanding-a-category.md |
|
||||
| `src/treg/providers.py` | interface/env-import.md |
|
||||
@@ -362,6 +364,7 @@ Regenerate via `scripts/build-map.py`.
|
||||
| `src/treg/routers/catalog.py` | architecture/catalog.md, interface/api.md |
|
||||
| `src/treg/routers/connections.py` | architecture/auth-secrets.md, architecture/composition.md, guides/expanding-a-category.md, interface/api.md |
|
||||
| `src/treg/routers/feedback.py` | architecture/feedback.md |
|
||||
| `src/treg/routers/media.py` | architecture/media.md, interface/api.md |
|
||||
| `src/treg/routers/onboard.py` | architecture/composition.md, interface/api.md, interface/landing-sandbox.md, interface/onboarding.md |
|
||||
| `src/treg/routers/orgs.py` | architecture/composition.md, architecture/money.md, architecture/multi-tenancy.md, interface/api.md |
|
||||
| `src/treg/routers/referrals.py` | architecture/composition.md, architecture/money.md, interface/api.md |
|
||||
@@ -475,6 +478,7 @@ Regenerate via `scripts/build-map.py`.
|
||||
| `tests/test_mcp.py` | architecture/mcp-oauth.md |
|
||||
| `tests/test_mcp_directory.py` | architecture/mcp-oauth.md |
|
||||
| `tests/test_mcp_oauth.py` | architecture/mcp-oauth.md |
|
||||
| `tests/test_media.py` | architecture/media.md |
|
||||
| `tests/test_oauth_billed.py` | architecture/proxy-model.md |
|
||||
| `tests/test_oauth_refresh.py` | architecture/auth-secrets.md |
|
||||
| `tests/test_passthrough.py` | architecture/proxy-model.md |
|
||||
@@ -509,6 +513,7 @@ Regenerate via `scripts/build-map.py`.
|
||||
| `architecture/local-proxy.md` | `localproxy.py`, `server.js` |
|
||||
| `architecture/local-run.md` | `localrun.py`, `egress.py`, `fsjail.py` |
|
||||
| `architecture/mcp-oauth.md` | `auth.py`, `mcp.py`, `health.py`, `mcp_oauth.py`, `session.py`, `access.py`, `api_keys.py`, `api_keys.py`, `auth.py`, `claude-connector.html`, `connect-demo.html`, `CLAUDE-CONNECTOR-SUBMISSION.md`, `test_mcp.py`, `test_mcp_oauth.py`, `test_mcp_directory.py`, `test_marketplace_call.py` |
|
||||
| `architecture/media.md` | `media.py`, `media.py`, `models.py`, `0037_media_hosting.py`, `test_media.py` |
|
||||
| `architecture/money.md` | `__init__.py`, `settlement.py`, `__init__.py`, `models.py`, `billing.py`, `idempotency.py`, `intake.py`, `resolve.py`, `service.py`, `reserve.py`, `settle.py`, `tomba.yaml`, `asynctasks.py`, `0017_async_task_record.py`, `0018_async_resource_ownership.py`, `0019_async_poll_failures.py`, `referrals.py`, `budgets.py`, `__init__.py`, `stripe.py`, `reconcile.py`, `referrals.py`, `api.py`, `signup.py`, `promotions.py`, `0033_signup_promo_eligibility.py`, `admin.py`, `billing.py`, `call.py`, `orgs.py`, `referrals.py`, `test_call_architecture.py`, `test_asynctasks.py` |
|
||||
| `architecture/multi-tenancy.md` | `models.py`, `api.py`, `caller_metadata.py`, `auth.py`, `asynctasks.py`, `resolve.py`, `signup.py`, `access.py`, `budgets.py`, `publicdemo.py`, `teams.py`, `usage.py`, `access.py`, `api_keys.py`, `session.py`, `promotions.py`, `test_team_limit.py`, `test_auth.py`, `test_token_revocation.py`, `auth.py`, `orgs.py`, `resources.py`, `bundles.py`, `db.py`, `0017_async_task_record.py`, `0018_async_resource_ownership.py`, `test_router_dependencies.py`, `test_asynctasks.py` |
|
||||
| `architecture/proxy-model.md` | `relay.py`, `ssrf.py`, `api.py`, `authorize.py`, `idempotency.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `asynctasks.py`, `client_identity.py`, `call_surface.py`, `sandbox_identity.py`, `access.py`, `publicdemo.py`, `usage.py`, `call.py`, `test_ssrf_public_addresses.py`, `test_call_application_contract.py`, `test_call_cancellation.py`, `test_call_response_limits.py`, `test_error_capture.py`, `test_marketplace_call.py`, `test_oauth_billed.py`, `test_passthrough.py`, `test_tag_billing.py`, `test_tag_billing_adversarial.py`, `test_call_architecture.py`, `test_asynctasks.py` |
|
||||
@@ -516,7 +521,7 @@ Regenerate via `scripts/build-map.py`.
|
||||
| `architecture/super-admin.md` | `api.py`, `admin.py`, `access.py`, `config.py` |
|
||||
| `foundation/charter.md` | `2026-06-30-jason-tools-registry.md`, `README.md` |
|
||||
| `guides/expanding-a-category.md` | `oauth_providers.py`, `authorization.py`, `oauth_flow.py`, `oauth_exchange.py`, `connect.py`, `connections.py`, `config.py` |
|
||||
| `interface/api.md` | `sitetrack.js`, `api.py`, `bootstrap_handlers.py`, `bootstrap_http.py`, `call_surface.py`, `caller_metadata.py`, `client_identity.py`, `auth.py`, `access.py`, `authorize.py`, `idempotency.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `relay.py`, `connect.py`, `onboard.py`, `referrals.py`, `signup.py`, `__init__.py`, `admin.py`, `auth.py`, `auth_helpers.py`, `billing.py`, `call.py`, `catalog.py`, `connections.py`, `onboard.py`, `orgs.py`, `api_keys.py`, `resources.py`, `referrals.py`, `signup_cookies.py`, `web.py`, `access.py`, `api_keys.py`, `teams.py`, `access.py`, `budgets.py`, `publicdemo.py`, `usage.py`, `mcp_oauth.py`, `session.py`, `timeutil.py`, `store.py`, `email.py`, `runner.py`, `ratestore.py` |
|
||||
| `interface/api.md` | `media.py`, `sitetrack.js`, `api.py`, `bootstrap_handlers.py`, `bootstrap_http.py`, `call_surface.py`, `caller_metadata.py`, `client_identity.py`, `auth.py`, `access.py`, `authorize.py`, `idempotency.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `relay.py`, `connect.py`, `onboard.py`, `referrals.py`, `signup.py`, `__init__.py`, `admin.py`, `auth.py`, `auth_helpers.py`, `billing.py`, `call.py`, `catalog.py`, `connections.py`, `onboard.py`, `orgs.py`, `api_keys.py`, `resources.py`, `referrals.py`, `signup_cookies.py`, `web.py`, `access.py`, `api_keys.py`, `teams.py`, `access.py`, `budgets.py`, `publicdemo.py`, `usage.py`, `mcp_oauth.py`, `session.py`, `timeutil.py`, `store.py`, `email.py`, `runner.py`, `ratestore.py` |
|
||||
| `interface/catalog-review-proposal.md` | `store.py`, `capabilities.yaml` |
|
||||
| `interface/cli.md` | `cli.py`, `test_released_cli_compat.py`, `test_cli_key_compatibility.py`, `auth_helpers.py`, `cli_analytics.py`, `convert.py`, `agents.py`, `api_keys.py`, `test_api_keys.py` |
|
||||
| `interface/dashboard.md` | `sitetrack.js`, `index.html`, `agent-setup.js`, `README.md`, `vue-3.5.41.global.prod.js`, `tutorial.js`, `tutorial.html`, `tour.js`, `index.html`, `api.py`, `web.py`, `session.py`, `api_keys.py`, `test_api_keys.py` |
|
||||
|
||||
@@ -137,6 +137,7 @@ treg tool add google-ads --base-url https://googleads.googleapis.com \
|
||||
| `treg catalog get` | `ENDPOINT_ID` | docs, parameters, **the price**, and how you would be served |
|
||||
| `treg call ENDPOINT_ID` | `--query K=V`, `--data STR` | call it |
|
||||
| `treg call ENDPOINT_ID --await` | `--timeout N` (default 900) | a generation call (video/image): submit, poll the provider, print the final response |
|
||||
| `treg host FILE` | `--content-type T`, `--json` | host a reference image/audio/video at a public URL a vendor can fetch (30 MB, 7-day TTL, free); prints the URL for `image_urls` / `audio_urls` |
|
||||
| `treg catalog request` | `"what's missing"` | searched, not there? file it — requests steer what gets added next |
|
||||
|
||||
```bash
|
||||
|
||||
@@ -30,6 +30,7 @@ covers (frontmatter `sources:`). Regenerate this index with
|
||||
| [Local proxy — catch a program's own outgoing calls (`treg <command>`)](architecture/local-proxy.md) | shipped | localproxy.py, server.js |
|
||||
| [Local CLI runs — run a vendor CLI as a dedicated user with a server-held credential (`treg run`)](architecture/local-run.md) | shipped | localrun.py, egress.py, fsjail.py |
|
||||
| [MCP — the front door for assistants, and treg as an OAuth authorization server](architecture/mcp-oauth.md) | shipped | auth.py, mcp.py, health.py, mcp_oauth.py, … |
|
||||
| [Media hosting - reference files a vendor can fetch (`treg host`)](architecture/media.md) | shipped | media.py, media.py, models.py, 0037_media_hosting.py, … |
|
||||
| [Money — prepaid balance, the ledger, Stripe, and the reports that check it](architecture/money.md) | shipped | __init__.py, settlement.py, __init__.py, models.py, … |
|
||||
| [Multi-tenancy — orgs, memberships, invites, per-org scoping](architecture/multi-tenancy.md) | shipped | models.py, api.py, caller_metadata.py, auth.py, … |
|
||||
| [The proxy — faithful credential-injecting relay + tool resolution](architecture/proxy-model.md) | shipped | relay.py, ssrf.py, api.py, authorize.py, … |
|
||||
@@ -40,7 +41,7 @@ covers (frontmatter `sources:`). Regenerate this index with
|
||||
|
||||
| Fragment | Status | Covers |
|
||||
|---|---|---|
|
||||
| [The API — the only brain (FastAPI)](interface/api.md) | shipped | sitetrack.js, api.py, bootstrap_handlers.py, bootstrap_http.py, … |
|
||||
| [The API — the only brain (FastAPI)](interface/api.md) | shipped | media.py, sitetrack.js, api.py, bootstrap_handlers.py, … |
|
||||
| [Catalog browse review — categories, platform placement, and domain sections](interface/catalog-review-proposal.md) | reference | store.py, capabilities.yaml |
|
||||
| [The CLI (treg) + skill scaffolding](interface/cli.md) | shipped | cli.py, test_released_cli_compat.py, test_cli_key_compatibility.py, auth_helpers.py, … |
|
||||
| [The web dashboard (Ledger, served from FastAPI)](interface/dashboard.md) | shipped | sitetrack.js, index.html, agent-setup.js, README.md, … |
|
||||
|
||||
@@ -154,7 +154,8 @@ Arena statistics. Dataplane processes do not run this collector; `/arena/insight
|
||||
Shutdown cancels and awaits every started background worker before draining Arena, audit and
|
||||
analytics or closing the shared client, so database rollback/close finishes before event-loop teardown.
|
||||
|
||||
`POST /reviews` and `GET /admin/reviews` belong to control, alongside feedback intake and reads.
|
||||
`POST /reviews` and `GET /admin/reviews` belong to control, alongside feedback intake and reads,
|
||||
as do `POST /media` and the public `GET /m/{token}` that serves a hosted reference file.
|
||||
|
||||
The archive object-store lifespan normalizes configuration once, chooses an R2 factory or
|
||||
injected in-memory context, and resets the store on exit. R2 validation runs before DB startup
|
||||
|
||||
@@ -98,6 +98,10 @@ See [signup eligibility](money.md#signup-credit-eligibility).
|
||||
category/message/references, authenticated org and user attribution, and the references verified
|
||||
against that team's call records or ledger. Revision `0025`; `domain.feedback` owns inserts;
|
||||
`application.feedback` commits. Team deletion removes these rows. See [feedback](feedback.md).
|
||||
- **`Media`** - a reference file a member hosted for a vendor to fetch (`treg host`): opaque
|
||||
token, org, media type, size, the bytes, created and expiry. Revision `0037`;
|
||||
`application.media` is the only writer and sweeps expired rows on each upload. Team deletion
|
||||
removes these rows. See [media](media.md).
|
||||
- **`FeedbackHandling` / `FeedbackHandlingEvent`** - internal current processing state and
|
||||
versioned history (revision 0030), owned by this schema and written only by the private admin
|
||||
service. Both cascade from the original report. See [feedback](feedback.md).
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: Media hosting - reference files a vendor can fetch (`treg host`)
|
||||
status: shipped
|
||||
sources:
|
||||
- src/treg/application/media.py
|
||||
- src/treg/routers/media.py
|
||||
- src/treg/models.py
|
||||
- src/treg/alembic/versions/0037_media_hosting.py
|
||||
- tests/test_media.py
|
||||
related:
|
||||
- architecture/proxy-model.md
|
||||
- interface/cli.md
|
||||
- interface/api.md
|
||||
---
|
||||
|
||||
# Media hosting
|
||||
|
||||
AIGC endpoints (reAPI, PiAPI, OpenRouter video) take reference images, voice clips and videos as
|
||||
public URLs that the vendor's fetcher downloads. An agent on a laptop has no host, and every paste
|
||||
host tried failed a vendor's pre-flight probe at least once: catbox unreachable from reAPI's
|
||||
fal-backed probe, tmpfiles answering HTML on its download link, uguu timing out. Five submissions
|
||||
to land one 18 s Seedance task was the cost on 2026-09-14. `treg host <file>` removes that class.
|
||||
|
||||
## Shape
|
||||
|
||||
- `POST /media` (member+, raw body, `Content-Type` names the media type) stores the bytes in the
|
||||
`media` table and answers `{url, token, content_type, size, expires_at}`. The URL is
|
||||
`<public_url>/m/<token>`.
|
||||
- `GET /m/{token}` is unauthenticated on purpose: the vendor's fetcher holds no treg token. The
|
||||
token is 192 random bits and the row names nothing about the org, so the URL is the only
|
||||
capability. Served inline with the stored type, `Cache-Control: public, max-age=3600` and
|
||||
`X-Robots-Tag: noindex`. No listing endpoint exists.
|
||||
- Bounds, all in `application/media.py`: 30 MB per file (the vendors' own reference limit),
|
||||
300 MB per org per rolling 24 h, 7-day TTL (reAPI's output URLs live about as long), and only
|
||||
`image/*`, `audio/*`, `video/*`, with SVG denied (an image type that carries script). Sandbox
|
||||
orgs are refused. The router refuses on `Content-Length` and again mid-stream at the cap, so an
|
||||
oversized body never reaches worker RAM in full.
|
||||
- Expiry is swept inline on every upload (`DELETE WHERE expires_at < now`) rather than by a cron;
|
||||
an expired row also 404s on read before the sweep reaches it.
|
||||
- Not metered. Hosting is a courtesy like polling, so money's five entries stay untouched and the
|
||||
call runtime never touches this table.
|
||||
|
||||
## Why bytes in the database
|
||||
|
||||
The archive's object store is private and configured per deployment (`archive_body_write` is
|
||||
`db` in production today). A row in Postgres serves a 30 MB reference correctly, keeps the feature
|
||||
one table and one router, and needs no public bucket. Move bodies to the object store when the
|
||||
media table's size, not its correctness, becomes the problem.
|
||||
|
||||
## Not built
|
||||
|
||||
- A treg-side pre-flight fetch of the URLs an agent passes to a vendor. It would model the
|
||||
vendor's probe, which non-negotiable 4 forbids, and it cannot see host blocks that apply only to
|
||||
the vendor's fetcher (catbox served us 200 while fal got nothing).
|
||||
- An MCP `host` tool. MCP clients that need it can `POST /media` directly; add the tool when an
|
||||
agent asks for it, and pin it in the MCP tool-set tests like the others.
|
||||
@@ -291,7 +291,8 @@ bearer path refuses it once expired rather than reviving an expired cookie.
|
||||
transfer = promote another to owner, then step down), `leave_org` (`POST /orgs/{id}/leave`, self-removal,
|
||||
same last-owner guard), `delete_org` (`DELETE /orgs/{id}`, owner-only, cascades every org-scoped row
|
||||
through `cascade_delete_org` / `ORG_SCOPED_MODELS` in `domain/governance/teams.py` - including any
|
||||
pending `AdConversion`: a queued conversion belongs to the team it would be attributed to).
|
||||
pending `AdConversion`: a queued conversion belongs to the team it would be attributed to, and
|
||||
`Media`: hosted reference files would otherwise outlive the team until their TTL).
|
||||
**That list is the only one.** Owner delete, admin force-delete, the landing-sandbox reaper and the
|
||||
demo reset all go through it; `test_org_delete_clears_EVERY_org_scoped_table` walks the models module
|
||||
for anything carrying `org_id` and also refuses a reaper that keeps a private copy. The sandbox reaper
|
||||
|
||||
@@ -179,7 +179,9 @@ cancellation cleanup, metering, audit, idempotency, and faithful relay.
|
||||
`base_url + path`. **No path → the base URL itself, without a trailing slash** - a tool pinned to a
|
||||
full resource (`.../v1/charges`) must relay as-is, since Stripe `404`s `/v1/charges/`.
|
||||
|
||||
Named misses also inspect the org's caller-usable own tools on the error path. When a dotted operation
|
||||
A named miss whose `<tool>` is an exact catalog id with a path behind it (`reapi.tasks.get/tasks/1`)
|
||||
is the own-tool shape applied to the catalog half: it answers `400` naming the endpoint's parameter
|
||||
slots and the `--query` form, before any hint below runs. Named misses also inspect the org's caller-usable own tools on the error path. When a dotted operation
|
||||
name shares its provider/first segment with one (for example `google-analytics.report` beside the
|
||||
connected `google-analytics` tool), the 404 carries `hint` plus `did_you_mean` and points at
|
||||
`/call/google-analytics/<path>`. If that dotted name is a real catalog endpoint, the hint follows the
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
title: The API — the only brain (FastAPI)
|
||||
status: shipped
|
||||
sources:
|
||||
- src/treg/routers/media.py
|
||||
- src/treg/web/sitetrack.js
|
||||
- src/treg/api.py
|
||||
- src/treg/bootstrap_handlers.py
|
||||
@@ -86,6 +87,14 @@ and super-admin `GET /admin/feedback`. The intake's transaction belongs to `appl
|
||||
the routes are in the control role. `routers.web.feedback_md` serves the compact instructions.
|
||||
See [feedback](../architecture/feedback.md) for the contract and provenance boundaries.
|
||||
|
||||
## Media hosting
|
||||
|
||||
`POST /media` (member+, raw body, `Content-Type` = the media type) stores a reference file and
|
||||
answers `{url, token, content_type, size, expires_at}`; `GET /m/{token}` serves it publicly, no
|
||||
token, because the vendor's fetcher has none. 30 MB per file, 300 MB per org per 24 h, 7-day TTL,
|
||||
`image/*` / `audio/*` / `video/*` only, refused in the sandbox. Refusals: 415 type, 413 size, 429
|
||||
quota, 403 sandbox. Not metered. See [media](../architecture/media.md).
|
||||
|
||||
## Composition
|
||||
|
||||
`api.router` preserves public registration order while concern routers contribute ordered route blocks.
|
||||
|
||||
@@ -584,3 +584,12 @@ Existing unscoped tokens retain their old team-create behavior. Fresh email logi
|
||||
with typed credentials require the updated CLI on the affected paths. This is a controlled upgrade
|
||||
requirement, not full support for all fresh-login flows in old clients. The released-wheel test in
|
||||
`test_released_cli_compat` checks that refusal preserves config bytes and the prior usable team.
|
||||
|
||||
## `treg host` - reference files for AIGC endpoints
|
||||
|
||||
`cmd_host` implements `treg host <file> [--content-type TYPE] [--json]`: one `POST /media` with the
|
||||
file's bytes and a type guessed from the extension, printing the public URL alone on stdout (size
|
||||
and expiry go to stderr) so `$(treg host face.jpg)` drops straight into a `--data` body. 30 MB per
|
||||
file, 7-day TTL, image / audio / video only, free. See
|
||||
[media](../architecture/media.md). `treg call <catalog-id> <path>` (the own-tool shape applied to a
|
||||
catalog id) now answers 400 naming the endpoint's parameter slots instead of "no tool in this org".
|
||||
|
||||
@@ -200,6 +200,10 @@ How it works:
|
||||
task id, a resumable `treg call …` command (Ctrl-C loses the wait, never the task or the money),
|
||||
progress, and the result URL. Exit 0 = done, 2 = the provider failed the task, 3 = timed out
|
||||
(resume with the printed command).
|
||||
- **Reference media (a face image, a voice clip, a first frame) must be a public URL the vendor
|
||||
can fetch.** Do not reach for a paste host: they fail vendor probes at random (catbox, tmpfiles,
|
||||
uguu all did). `treg host face.jpg` prints a public URL (30 MB, 7 days, free) that drops straight
|
||||
into `image_urls` / `audio_urls`: `--data "{\"image_urls\":[\"$(treg host face.jpg)\"], …}"`.
|
||||
- **CLI agents: raise your shell tool's timeout or run the call in the background.** A video takes
|
||||
1-5 minutes; a runtime's default 2-minute command limit cuts it off mid-wait.
|
||||
- **MCP and raw-HTTP agents:** the response header `X-Treg-Async` is the descriptor - where to poll,
|
||||
|
||||
@@ -203,6 +203,10 @@ How it works:
|
||||
task id, a resumable `treg call …` command (Ctrl-C loses the wait, never the task or the money),
|
||||
progress, and the result URL. Exit 0 = done, 2 = the provider failed the task, 3 = timed out
|
||||
(resume with the printed command).
|
||||
- **Reference media (a face image, a voice clip, a first frame) must be a public URL the vendor
|
||||
can fetch.** Do not reach for a paste host: they fail vendor probes at random (catbox, tmpfiles,
|
||||
uguu all did). `treg host face.jpg` prints a public URL (30 MB, 7 days, free) that drops straight
|
||||
into `image_urls` / `audio_urls`: `--data "{\"image_urls\":[\"$(treg host face.jpg)\"], …}"`.
|
||||
- **CLI agents: raise your shell tool's timeout or run the call in the background.** A video takes
|
||||
1-5 minutes; a runtime's default 2-minute command limit cuts it off mid-wait.
|
||||
- **MCP and raw-HTTP agents:** the response header `X-Treg-Async` is the descriptor - where to poll,
|
||||
|
||||
@@ -184,6 +184,10 @@ How it works:
|
||||
task id, a resumable `treg call …` command (Ctrl-C loses the wait, never the task or the money),
|
||||
progress, and the result URL. Exit 0 = done, 2 = the provider failed the task, 3 = timed out
|
||||
(resume with the printed command).
|
||||
- **Reference media (a face image, a voice clip, a first frame) must be a public URL the vendor
|
||||
can fetch.** Do not reach for a paste host: they fail vendor probes at random (catbox, tmpfiles,
|
||||
uguu all did). `treg host face.jpg` prints a public URL (30 MB, 7 days, free) that drops straight
|
||||
into `image_urls` / `audio_urls`: `--data "{\"image_urls\":[\"$(treg host face.jpg)\"], …}"`.
|
||||
- **CLI agents: raise your shell tool's timeout or run the call in the background.** A video takes
|
||||
1-5 minutes; a runtime's default 2-minute command limit cuts it off mid-wait.
|
||||
- **MCP and raw-HTTP agents:** the response header `X-Treg-Async` is the descriptor - where to poll,
|
||||
|
||||
@@ -198,6 +198,10 @@ How it works:
|
||||
task id, a resumable `treg call …` command (Ctrl-C loses the wait, never the task or the money),
|
||||
progress, and the result URL. Exit 0 = done, 2 = the provider failed the task, 3 = timed out
|
||||
(resume with the printed command).
|
||||
- **Reference media (a face image, a voice clip, a first frame) must be a public URL the vendor
|
||||
can fetch.** Do not reach for a paste host: they fail vendor probes at random (catbox, tmpfiles,
|
||||
uguu all did). `treg host face.jpg` prints a public URL (30 MB, 7 days, free) that drops straight
|
||||
into `image_urls` / `audio_urls`: `--data "{\"image_urls\":[\"$(treg host face.jpg)\"], …}"`.
|
||||
- **CLI agents: raise your shell tool's timeout or run the call in the background.** A video takes
|
||||
1-5 minutes; a runtime's default 2-minute command limit cuts it off mid-wait.
|
||||
- **MCP and raw-HTTP agents:** the response header `X-Treg-Async` is the descriptor - where to poll,
|
||||
|
||||
@@ -182,6 +182,10 @@ How it works:
|
||||
task id, a resumable `treg call …` command (Ctrl-C loses the wait, never the task or the money),
|
||||
progress, and the result URL. Exit 0 = done, 2 = the provider failed the task, 3 = timed out
|
||||
(resume with the printed command).
|
||||
- **Reference media (a face image, a voice clip, a first frame) must be a public URL the vendor
|
||||
can fetch.** Do not reach for a paste host: they fail vendor probes at random (catbox, tmpfiles,
|
||||
uguu all did). `treg host face.jpg` prints a public URL (30 MB, 7 days, free) that drops straight
|
||||
into `image_urls` / `audio_urls`: `--data "{\"image_urls\":[\"$(treg host face.jpg)\"], …}"`.
|
||||
- **CLI agents: raise your shell tool's timeout or run the call in the background.** A video takes
|
||||
1-5 minutes; a runtime's default 2-minute command limit cuts it off mid-wait.
|
||||
- **MCP and raw-HTTP agents:** the response header `X-Treg-Async` is the descriptor - where to poll,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""media: hosted reference files for vendors to fetch (`treg host`)
|
||||
|
||||
Revision ID: 0037
|
||||
Revises: 0036
|
||||
Create Date: 2026-09-14
|
||||
|
||||
Additive: one new table, nothing else touched.
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
revision: str = "0037"
|
||||
down_revision: str | Sequence[str] | None = "0036"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"media",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("content_type", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column("size", sa.Integer(), nullable=False),
|
||||
sa.Column("body", sa.LargeBinary(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["org.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_media_token", "media", ["token"], unique=True)
|
||||
op.create_index("ix_media_expires_at", "media", ["expires_at"])
|
||||
op.create_index("ix_media_org_created", "media", ["org_id", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("media")
|
||||
@@ -57,6 +57,7 @@ from .routers import call as call_routes
|
||||
from .routers import catalog as catalog_routes
|
||||
from .routers import connections as connection_routes
|
||||
from .routers import feedback as feedback_routes
|
||||
from .routers import media as media_routes
|
||||
from .routers import onboard as onboard_routes
|
||||
from .routers import orgs as org_routes
|
||||
from .routers import referrals as referral_routes
|
||||
@@ -301,6 +302,7 @@ async def create_tool_request(
|
||||
|
||||
|
||||
router.routes.extend(feedback_routes.app.routes)
|
||||
router.routes.extend(media_routes.app.routes)
|
||||
router.routes.extend(auth_routes.social_router.routes)
|
||||
router.routes.extend(auth_routes.cli_router.routes) # CLI pairing
|
||||
router.routes.extend(auth_routes.session_router.routes)
|
||||
|
||||
@@ -135,6 +135,21 @@ async def _resolve_call(rest: str, caller: Caller, db: AsyncSession) -> Resolved
|
||||
).scalar_one_or_none()
|
||||
if tool is None:
|
||||
cat = catalog_store.load()
|
||||
# An EXACT catalog id followed by a URL path (`reapi.tasks.get/tasks/<id>`) is the own-tool
|
||||
# shape applied to the catalog half: the caller had the right id and wrote the vendor's path
|
||||
# by hand. "no tool in this org" sends them hunting in the wrong half of treg; name the
|
||||
# endpoint's real parameter slots instead so the next call is the right one.
|
||||
if path and (ep := cat.by_id.get(name)) is not None:
|
||||
inp = ep.get("input") or {}
|
||||
slots = sorted({k for sec in ("pathParams", "queryParams", "query", "body")
|
||||
for k in (inp.get(sec) or {})})
|
||||
raise ResolutionFailed(
|
||||
"invalid_target", status_code=400, detail={
|
||||
"error": f"{name!r} is a catalog endpoint and takes no URL path",
|
||||
"hint": (f"pass parameters as --query K=V (path and query params) or --data "
|
||||
f"'{{…}}' (body): treg call {name} --query <k>=<v>"
|
||||
+ (f"; parameters: {', '.join(slots)}" if slots else "")),
|
||||
"parameters": slots})
|
||||
# A DOTTED name that reached here was meant to be a catalog endpoint id and missed — a
|
||||
# near-miss id, most often one segment off. Answering "no tool 'lusha.companies-signals' in
|
||||
# this org" describes the wrong half of treg and leaves the caller nothing to try; naming
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Host a reference file so a vendor can fetch it: the one use case behind `treg host`.
|
||||
|
||||
AIGC endpoints (reAPI, PiAPI, OpenRouter video) take reference images, voice clips and videos as
|
||||
public URLs their fetcher downloads. An agent on a laptop has no host, and every paste host we tried
|
||||
failed a vendor probe at least once (catbox unreachable from fal, tmpfiles answering HTML, uguu
|
||||
timing out). This keeps the bytes in treg and serves them from an opaque token.
|
||||
|
||||
Deliberately not metered: hosting is a courtesy like polling, so money's five entries stay untouched.
|
||||
Bounded instead: a size cap per file, a daily byte quota per org, a TTL, and a media-only type list.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
import secrets
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from ..infra.db import session_maker
|
||||
from ..models import Media, _now
|
||||
|
||||
MAX_BYTES = 30 * 1024 * 1024 # matches the vendors' per-reference limits
|
||||
DAILY_ORG_BYTES = 300 * 1024 * 1024 # ten maximal files a day; a runaway agent stops there
|
||||
TTL = timedelta(days=7) # reAPI's own output URLs live about this long
|
||||
ALLOWED_PREFIXES = ("image/", "audio/", "video/")
|
||||
# SVG is an image type that carries script. Served inline on the treg.to origin it would run with a
|
||||
# logged-in viewer's session, and no vendor takes an SVG reference anyway.
|
||||
DENIED_TYPES = frozenset({"image/svg+xml", "image/svg"})
|
||||
|
||||
|
||||
class MediaRejected(Exception):
|
||||
def __init__(self, status_code: int, detail: str):
|
||||
self.status_code, self.detail = status_code, detail
|
||||
super().__init__(detail)
|
||||
|
||||
|
||||
async def put(*, org_id: int, body: bytes, content_type: str) -> Media:
|
||||
ctype = (content_type or "").split(";", 1)[0].strip().lower()
|
||||
if not ctype.startswith(ALLOWED_PREFIXES) or ctype in DENIED_TYPES:
|
||||
raise MediaRejected(415, f"only image/*, audio/* and video/* (not SVG) can be hosted, not {ctype or 'an untyped body'!r}")
|
||||
if not body:
|
||||
raise MediaRejected(400, "empty body")
|
||||
if len(body) > MAX_BYTES:
|
||||
raise MediaRejected(413, f"file is {len(body)} bytes; the cap is {MAX_BYTES}")
|
||||
now = _now()
|
||||
async with session_maker() as db:
|
||||
# ponytail: expiry is swept inline on every upload rather than by a cron; a dedicated
|
||||
# sweep only matters once uploads are rare and the table is not.
|
||||
await db.execute(delete(Media).where(Media.expires_at < now))
|
||||
# ponytail: read-then-insert, so two concurrent uploads can overshoot the quota by one file;
|
||||
# a row lock per org is the upgrade if the courtesy ever gets abused.
|
||||
used = (await db.execute(select(func.coalesce(func.sum(Media.size), 0)).where(
|
||||
Media.org_id == org_id, Media.created_at >= now - timedelta(days=1)))).scalar_one()
|
||||
if used + len(body) > DAILY_ORG_BYTES:
|
||||
raise MediaRejected(429, f"this team has hosted {used} bytes in the last 24 h; the daily quota is {DAILY_ORG_BYTES}")
|
||||
row = Media(token=secrets.token_urlsafe(24), org_id=org_id, content_type=ctype,
|
||||
size=len(body), body=body, created_at=now, expires_at=now + TTL)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
return row # every field the router answers with was set above; no re-read of the body
|
||||
|
||||
|
||||
async def get(token: str) -> Media | None:
|
||||
async with session_maker() as db:
|
||||
row = (await db.execute(select(Media).where(Media.token == token))).scalar_one_or_none()
|
||||
if row is None or row.expires_at < _now():
|
||||
return None
|
||||
return row
|
||||
@@ -93,6 +93,8 @@ _CONTROL_ROUTE_KEYS: frozenset[RouteKey] = frozenset({
|
||||
('/reviews', ('POST',), 'submit_review'),
|
||||
('/admin/reviews', ('GET',), 'admin_reviews'),
|
||||
('/feedback/{feedback_id}', ('GET',), 'get_feedback'),
|
||||
('/media', ('POST',), 'host_media'),
|
||||
('/m/{token}', ('GET',), 'serve_media'),
|
||||
('/admin/feedback', ('GET',), 'admin_feedback'),
|
||||
('/auth/github', ('GET',), 'auth_github'),
|
||||
('/auth/github/callback', ('GET',), 'auth_github_callback'),
|
||||
|
||||
+36
-1
@@ -5105,6 +5105,30 @@ def cmd_review(args, cfg) -> None:
|
||||
print(json.dumps(receipt, indent=2))
|
||||
|
||||
|
||||
def cmd_host(args, cfg) -> None:
|
||||
"""`treg host <file>`: host a reference image / audio / video so a vendor can fetch it by URL.
|
||||
AIGC endpoints take references as public URLs; paste hosts fail vendor probes at random, and an
|
||||
agent on a laptop has nothing better. Prints the URL alone so it drops straight into --data."""
|
||||
import mimetypes
|
||||
p = Path(args.file).expanduser()
|
||||
if not p.is_file():
|
||||
sys.exit(f"treg host: file not found: {p}")
|
||||
ctype = args.content_type or mimetypes.guess_type(p.name)[0] or ""
|
||||
if not ctype:
|
||||
sys.exit(f"treg host: cannot guess the media type of {p.name}; pass --content-type image/png (or audio/*, video/*)")
|
||||
with _client(cfg) as c:
|
||||
r = c.post("/media", content=p.read_bytes(), headers={"content-type": ctype})
|
||||
if r.status_code >= 400:
|
||||
_show(r)
|
||||
sys.exit(1)
|
||||
body = r.json()
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(body, indent=2))
|
||||
else:
|
||||
print(body["url"])
|
||||
print(f" {body['content_type']}, {body['size']} bytes, expires {body['expires_at']}", file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_feedback(args, cfg) -> None:
|
||||
if args.message == "-" and sys.stdin.isatty():
|
||||
_feedback_error("stdin_required", "Pipe sanitized text or redirect a file into stdin when using '-'.")
|
||||
@@ -5508,6 +5532,7 @@ HELP_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [
|
||||
("THE CATALOG — tools you don't have a key for", [
|
||||
("catalog", "Find a tool by what you want to DO. ~2,600 endpoints, each with its price."),
|
||||
("call", "Call a tool: a catalog endpoint by id, or one of your own by URL."),
|
||||
("host", "Host a reference image / audio / video at a public URL for a vendor to fetch."),
|
||||
("balance", "Prepaid balance: credit left, calls in flight, recent spend."),
|
||||
("topup", "Add funds, or set up automatic top-ups."),
|
||||
("feedback", "Share a problem or suggestion about treg."),
|
||||
@@ -5879,7 +5904,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
# ---- calling ----
|
||||
cl = mk(sub, "call", "Call a tool through the proxy: `call <tool> <path>` or `call <full-url>`. Key injected server-side.",
|
||||
"treg call stripe v1/charges", "treg call https://api.stripe.com/v1/charges",
|
||||
"treg call posthog api/events --query limit=5", "treg call slack chat.postMessage --method POST --data '{\"channel\":\"C1\"}'")
|
||||
"treg call posthog api/events --query limit=5", "treg call slack chat.postMessage --method POST --data '{\"channel\":\"C1\"}'",
|
||||
"treg call reapi.tasks.get --query id=task_01a09ddf # a catalog id: path/query params go in --query, never in a path")
|
||||
cl.add_argument("target", help="a tool name, or a full upstream URL")
|
||||
cl.add_argument("path", nargs="?", default="", help="the path when using a tool name")
|
||||
cl.add_argument("--method", default=None,
|
||||
@@ -6144,6 +6170,15 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
review.add_argument("--reason", help="optional sanitized reason, 1-200 characters")
|
||||
review.set_defaults(fn=cmd_review)
|
||||
|
||||
ho = mk(sub, "host", "Host a reference file (image / audio / video) at a public URL that a vendor can fetch: "
|
||||
"the image_urls / audio_urls an AIGC endpoint takes. 30 MB per file, 7-day TTL, free.",
|
||||
"treg host face.jpg", "treg host voice.mp3 --content-type audio/mpeg",
|
||||
"treg call reapi.video-gen.seedance-2-5 --data \"{\\\"image_urls\\\":[\\\"$(treg host face.jpg)\\\"], …}\"")
|
||||
ho.add_argument("file", help="the local file to host")
|
||||
ho.add_argument("--content-type", dest="content_type", metavar="TYPE", help="override the type guessed from the extension")
|
||||
ho.add_argument("--json", action="store_true", help="print the full response (url, token, size, expires_at)")
|
||||
ho.set_defaults(fn=cmd_host)
|
||||
|
||||
fb = mk(sub, "feedback", "Submit or retrieve private team feedback.",
|
||||
'treg feedback submit friction "The pagination example is unclear."',
|
||||
'treg feedback submit quality "The result is outdated." --call-id CALL_ID --endpoint-id PROVIDER.ENDPOINT',
|
||||
|
||||
@@ -21,6 +21,7 @@ from ...models import (
|
||||
DenyRule,
|
||||
Feedback,
|
||||
CallReview,
|
||||
Media,
|
||||
Hold,
|
||||
IdempotentCall,
|
||||
Invite,
|
||||
@@ -174,6 +175,7 @@ ORG_SCOPED_MODELS = (
|
||||
ToolRequest, # attribution rows go with the team; anonymous filings carry no org_id and stay
|
||||
Feedback,
|
||||
CallReview,
|
||||
Media, # hosted reference files expire on their own; a deleted team's go now
|
||||
AdConversion, # pending Google Ads conversions belong to the team they'd be attributed to
|
||||
Membership, # last: it is what makes the caller a member of the org being deleted
|
||||
)
|
||||
|
||||
@@ -1282,6 +1282,23 @@ class CallReview(SQLModel, table=True):
|
||||
created_at: datetime = Field(default_factory=_now)
|
||||
|
||||
|
||||
class Media(SQLModel, table=True):
|
||||
"""A reference file a member hosted for a vendor to fetch (`treg host`): the image, voice clip
|
||||
or video an AIGC endpoint takes as a public URL. Bytes live in the row, expire by TTL, and are
|
||||
served by an opaque token that names no org or file. See docs/context/architecture/media.md.
|
||||
"""
|
||||
|
||||
__table_args__ = (Index("ix_media_org_created", "org_id", "created_at"),)
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
token: str = Field(unique=True, index=True)
|
||||
org_id: int = Field(foreign_key="org.id")
|
||||
content_type: str
|
||||
size: int = Field(default=0)
|
||||
body: bytes
|
||||
created_at: datetime = Field(default_factory=_now)
|
||||
expires_at: datetime = Field(index=True)
|
||||
|
||||
|
||||
class ToolRequest(SQLModel, table=True):
|
||||
"""A "the catalog doesn't have X" report — filed from the catalog page, the CLI, or by an
|
||||
agent mid-search over MCP. Demand signal for which provider to key next; reviewed by querying
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""`POST /media` hosts a reference file for a vendor to fetch; `GET /m/{token}` serves it."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
|
||||
from .. import sandbox as demo_sandbox
|
||||
from ..application import media as media_app
|
||||
from ..config import get_settings
|
||||
from ..domain.identity.access import Caller, require_member
|
||||
|
||||
app = APIRouter()
|
||||
|
||||
|
||||
@app.post("/media", status_code=201)
|
||||
async def host_media(request: Request, caller: Caller = Depends(require_member)) -> dict:
|
||||
"""Raw body in, public URL out. The Content-Type header names the media type; there is no
|
||||
multipart wrapper because the CLI and an agent's HTTP client both send bytes more simply."""
|
||||
if demo_sandbox.is_sandbox(caller.org):
|
||||
raise HTTPException(status_code=403, detail="hosting is disabled in the sandbox")
|
||||
# Refuse before buffering: no body-size middleware exists, so a declared or streamed body past
|
||||
# the cap must never reach worker RAM in full.
|
||||
declared = request.headers.get("content-length")
|
||||
if declared and declared.isdigit() and int(declared) > media_app.MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"file is {declared} bytes; the cap is {media_app.MAX_BYTES}")
|
||||
chunks, total = [], 0
|
||||
async for chunk in request.stream():
|
||||
total += len(chunk)
|
||||
if total > media_app.MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"file exceeds the {media_app.MAX_BYTES}-byte cap")
|
||||
chunks.append(chunk)
|
||||
try:
|
||||
row = await media_app.put(org_id=caller.org_id, body=b"".join(chunks),
|
||||
content_type=request.headers.get("content-type", ""))
|
||||
except media_app.MediaRejected as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.detail) from None
|
||||
url = f"{get_settings().public_url.rstrip('/')}/m/{row.token}"
|
||||
return {"url": url, "token": row.token, "content_type": row.content_type, "size": row.size,
|
||||
"expires_at": row.expires_at.isoformat() + "Z"}
|
||||
|
||||
|
||||
@app.get("/m/{token}", include_in_schema=False)
|
||||
async def serve_media(token: str) -> Response:
|
||||
"""Public by design: the vendor's fetcher holds no treg token. The token is 192 random bits
|
||||
and the row names nothing about the org, so the URL is the only capability."""
|
||||
row = await media_app.get(token)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="no such media, or it expired")
|
||||
# `sandbox` CSP + nosniff: user bytes served on the treg.to origin must be inert even if a
|
||||
# scriptable type ever slips past the allow-list. Vendors' fetchers ignore both headers.
|
||||
return Response(content=row.body, media_type=row.content_type, headers={
|
||||
"Content-Length": str(row.size), "Cache-Control": "public, max-age=3600",
|
||||
"X-Robots-Tag": "noindex", "Content-Disposition": "inline",
|
||||
"Content-Security-Policy": "sandbox", "X-Content-Type-Options": "nosniff"})
|
||||
@@ -480,6 +480,8 @@ are both supported; you do not need to pre-arrange anything with us.
|
||||
treg call <tool> <path> | treg call <full-url> proxy an HTTP call (named or agent-native)
|
||||
treg call <endpoint-id> --await [--timeout N] a catalog call that is an async task (generation):
|
||||
submit, poll, print the final response (default 900 s)
|
||||
treg host <file> [--content-type T] host a reference image/audio/video at a public URL a
|
||||
vendor can fetch (30 MB, 7 days, free) — for image_urls / audio_urls
|
||||
treg calls [--limit N] the proxied-call audit log
|
||||
treg audit [--limit N] [--calls | --runs] calls + CLI runs in one log; generation tasks carry
|
||||
their state and result link
|
||||
|
||||
@@ -163,6 +163,10 @@ How it works:
|
||||
task id, a resumable `treg call …` command (Ctrl-C loses the wait, never the task or the money),
|
||||
progress, and the result URL. Exit 0 = done, 2 = the provider failed the task, 3 = timed out
|
||||
(resume with the printed command).
|
||||
- **Reference media (a face image, a voice clip, a first frame) must be a public URL the vendor
|
||||
can fetch.** Do not reach for a paste host: they fail vendor probes at random (catbox, tmpfiles,
|
||||
uguu all did). `treg host face.jpg` prints a public URL (30 MB, 7 days, free) that drops straight
|
||||
into `image_urls` / `audio_urls`: `--data "{\"image_urls\":[\"$(treg host face.jpg)\"], …}"`.
|
||||
- **CLI agents: raise your shell tool's timeout or run the call in the background.** A video takes
|
||||
1-5 minutes; a runtime's default 2-minute command limit cuts it off mid-wait.
|
||||
- **MCP and raw-HTTP agents:** the response header `X-Treg-Async` is the descriptor - where to poll,
|
||||
|
||||
@@ -6463,6 +6463,69 @@
|
||||
"summary": "Accept My Invite"
|
||||
}
|
||||
},
|
||||
"/media": {
|
||||
"post": {
|
||||
"description": "Raw body in, public URL out. The Content-Type header names the media type; there is no\nmultipart wrapper because the CLI and an agent's HTTP client both send bytes more simply.",
|
||||
"operationId": "host_media_media_post",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "header",
|
||||
"name": "x-treg-token",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": "",
|
||||
"title": "X-Treg-Token",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "header",
|
||||
"name": "x-treg-org",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": "",
|
||||
"title": "X-Treg-Org",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"in": "cookie",
|
||||
"name": "treg_session",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": "",
|
||||
"title": "Treg Session",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": true,
|
||||
"title": "Response Host Media Media Post",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Host Media"
|
||||
}
|
||||
},
|
||||
"/meta": {
|
||||
"get": {
|
||||
"description": "Open: what the dashboard needs to render correct, shareable snippets — the public proxy URL\n(so copy/paste snippets use the real domain, not whatever origin the browser happens to be on)\n— plus the bundle version, so an open tab can detect a new deploy and offer a refresh.\n\n`treg_version` and `app_version` answer DIFFERENT questions and both are worth having.\n`app_version` is a hash of index.html: it changes whenever the dashboard bundle does, which is\nwhat an open tab compares to offer a refresh. `treg_version` is the released package version,\nwhich is what a release check needs — after publishing 0.9.0 there was no way to confirm from the\nlive path which version was actually serving, only the commit id.",
|
||||
|
||||
@@ -302,6 +302,23 @@
|
||||
"name": "admin_reviews",
|
||||
"path": "/admin/reviews"
|
||||
},
|
||||
{
|
||||
"kind": "APIRoute",
|
||||
"methods": [
|
||||
"POST"
|
||||
],
|
||||
"name": "host_media",
|
||||
"path": "/media"
|
||||
},
|
||||
{
|
||||
"kind": "APIRoute",
|
||||
"methods": [
|
||||
"GET",
|
||||
"HEAD"
|
||||
],
|
||||
"name": "serve_media",
|
||||
"path": "/m/{token}"
|
||||
},
|
||||
{
|
||||
"kind": "APIRoute",
|
||||
"methods": [
|
||||
|
||||
@@ -40,6 +40,7 @@ EXPECTED_MAKERS: dict[str, set[str]] = {
|
||||
"application/arena_verification_insights.py": {API}, # explicit aggregate publication, no worker
|
||||
|
||||
"application/feedback.py": {API}, # synchronous intake; admin reads use get_admin_session
|
||||
"application/media.py": {API}, # `treg host`: one short write, one short read, no upstream wait
|
||||
|
||||
"application/referrals.py": {API}, "application/signup.py": {API},
|
||||
"application/onboard/__init__.py": {API},
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""`treg host`: reference-file hosting for AIGC endpoints (docs/context/architecture/media.md)."""
|
||||
from datetime import timedelta
|
||||
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import update
|
||||
|
||||
from treg.application import media as media_app
|
||||
from treg.infra.db import session_maker
|
||||
from treg.models import Media, _now
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
||||
|
||||
|
||||
async def test_host_then_fetch_roundtrip_is_byte_exact_and_public(clients: AsyncClient):
|
||||
r = await clients.post("/media", content=PNG, headers={"content-type": "image/png"})
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["url"].endswith("/m/" + body["token"]) and body["size"] == len(PNG)
|
||||
# The vendor's fetcher holds no treg token: the URL alone must serve the bytes.
|
||||
anon = AsyncClient(transport=clients._transport, base_url="http://registry")
|
||||
g = await anon.get(f"/m/{body['token']}")
|
||||
assert g.status_code == 200 and g.content == PNG and g.headers["content-type"] == "image/png"
|
||||
assert g.headers["content-security-policy"] == "sandbox" and g.headers["x-content-type-options"] == "nosniff"
|
||||
|
||||
|
||||
async def test_hosting_needs_a_member_token(clients: AsyncClient):
|
||||
anon = AsyncClient(transport=clients._transport, base_url="http://registry")
|
||||
r = await anon.post("/media", content=PNG, headers={"content-type": "image/png"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
async def test_only_media_types_are_hosted(clients: AsyncClient):
|
||||
r = await clients.post("/media", content=b"<html>", headers={"content-type": "text/html"})
|
||||
assert r.status_code == 415
|
||||
r = await clients.post("/media", content=b"{}", headers={"content-type": "application/json"})
|
||||
assert r.status_code == 415
|
||||
# An SVG is an image that carries script; inline on our origin it would be stored XSS.
|
||||
r = await clients.post("/media", content=b"<svg onload=alert(1)/>", headers={"content-type": "image/svg+xml"})
|
||||
assert r.status_code == 415
|
||||
|
||||
|
||||
async def test_the_per_file_cap_and_daily_quota_refuse_before_storing(clients: AsyncClient, monkeypatch):
|
||||
monkeypatch.setattr(media_app, "MAX_BYTES", 100)
|
||||
r = await clients.post("/media", content=b"x" * 101, headers={"content-type": "audio/mpeg"})
|
||||
assert r.status_code == 413 # refused on Content-Length, before the body is read
|
||||
async def chunked():
|
||||
yield b"x" * 60; yield b"x" * 60
|
||||
r = await clients.post("/media", content=chunked(), headers={"content-type": "audio/mpeg"})
|
||||
assert r.status_code == 413 # chunked, no Content-Length: refused mid-stream at the cap
|
||||
monkeypatch.setattr(media_app, "DAILY_ORG_BYTES", 150)
|
||||
assert (await clients.post("/media", content=b"x" * 100, headers={"content-type": "audio/mpeg"})).status_code == 201
|
||||
r = await clients.post("/media", content=b"x" * 100, headers={"content-type": "audio/mpeg"})
|
||||
assert r.status_code == 429
|
||||
|
||||
|
||||
async def test_expired_media_is_gone_and_swept_by_the_next_upload(clients: AsyncClient):
|
||||
tok = (await clients.post("/media", content=PNG, headers={"content-type": "image/png"})).json()["token"]
|
||||
async with session_maker() as db:
|
||||
await db.execute(update(Media).where(Media.token == tok).values(expires_at=_now() - timedelta(seconds=1)))
|
||||
await db.commit()
|
||||
assert (await clients.get(f"/m/{tok}")).status_code == 404
|
||||
await clients.post("/media", content=PNG, headers={"content-type": "image/png"})
|
||||
assert await media_app.get(tok) is None
|
||||
async with session_maker() as db:
|
||||
assert (await db.execute(Media.__table__.select().where(Media.token == tok))).first() is None
|
||||
|
||||
|
||||
async def test_unknown_token_is_404(clients: AsyncClient):
|
||||
assert (await clients.get("/m/nope")).status_code == 404
|
||||
@@ -199,3 +199,15 @@ async def test_a_credential_dead_end_names_a_sibling_treg_can_already_serve(
|
||||
assert "callable now on treg's key" not in unkeyed
|
||||
assert "needs your own scrapecreators credential" in unkeyed
|
||||
A.get_settings.cache_clear()
|
||||
|
||||
|
||||
async def test_a_catalog_id_with_a_url_path_names_the_parameter_slots(clients: AsyncClient):
|
||||
"""`treg call reapi.tasks.get tasks/<id>` is the own-tool shape applied to a catalog id. The old
|
||||
answer, "no tool 'reapi.tasks.get' in this org", described the wrong half of treg; the caller
|
||||
had the right id and only needed to know where the parameter goes."""
|
||||
r = await clients.get("/call/reapi.tasks.get/tasks/task_01")
|
||||
assert r.status_code == 400
|
||||
detail = r.json()["detail"]
|
||||
assert "catalog endpoint" in detail["error"]
|
||||
assert detail["parameters"] == ["id"]
|
||||
assert "--query" in detail["hint"]
|
||||
|
||||
Reference in New Issue
Block a user